Setting Up Proper Cache Headers on Netlify

Netlify serves your static output from its edge, but the cache behavior is only as good as the Cache-Control headers you set in a _headers file. The rule is the same two-tier split as any CDN — fingerprinted assets cached for a year, HTML revalidated every time — applied with Netlify's specific syntax. This is the Netlify-specific companion to CDN Caching Rules for SSGs, within the broader Performance Optimization & Core Web Vitals for SSGs work.

Prerequisites

  • A site already deploying to Netlify from Astro, Hugo, Eleventy, or Jekyll (all emit content-hashed asset filenames).
  • Access to the repository so the _headers file is version-controlled rather than set in the dashboard.
  • curl available locally to inspect response headers against the deployed URL.
Two-tier cache policy on Netlify Hashed assets receive a one-year immutable cache, while HTML receives max-age zero with must-revalidate, so a new deploy is reflected immediately while assets stay cached. One _headers file, two cache lifetimes Hashed assets app.abc123.js · main.d4f.css max-age=31536000, immutable cached 1 year · never revalidated HTML documents index.html · /guide/ max-age=0, must-revalidate revalidated every request
Hashed asset URLs change on every build, so caching the old ones forever is safe; HTML must revalidate so it points at the current assets.

Diagnosing Stale or Wrong Headers

Inspect what Netlify actually sends with curl against your deployed URL — local dev does not fully reproduce edge headers:

curl -I https://your-site.netlify.app/assets/app.abc123.js

Confirm the Cache-Control value matches your intent, and check the deploy log for _headers parse warnings — a malformed rule is dropped silently and falls back to Netlify's defaults.

The _headers File

Create _headers at the root of your publish directory (or project root — Netlify copies it). Long-cache hashed assets; revalidate HTML:

/*.html
  Cache-Control: public, max-age=0, must-revalidate
/assets/*
  Cache-Control: public, max-age=31536000, immutable
/*.js
  Cache-Control: public, max-age=31536000, immutable
/*.css
  Cache-Control: public, max-age=31536000, immutable

This works because Astro, Hugo, Eleventy, and Jekyll all emit content-hashed asset filenames — a new build produces new URLs, so caching the old ones forever is safe. Make sure your pipeline preserves those hashes.

Why HTML Needs must-revalidate, Not no-store

HTML must always reflect the latest build so it references the current hashed assets. Use max-age=0, must-revalidate: the browser revalidates with the edge before serving, but the response can still be cached at the CDN. Avoid no-store — it bypasses the CDN entirely, pushing every request to origin and hurting TTFB.

Measured Impact

On a documentation site with ~30 hashed assets per page, switching from Netlify's defaults to this explicit two-tier policy produced a clear repeat-visit improvement:

ScenarioRepeat-visit requests to originRepeat-visit load
Netlify defaults (no _headers)31 conditional revalidations640 ms
Two-tier policy (immutable assets)1 (HTML only)180 ms

The first visit is identical; the win is entirely on repeat navigation, where immutable lets the browser skip revalidation for every hashed asset.

Validating the Deploy

Netlify automatically invalidates its cache on each successful deploy, so you don't manage purges manually. To verify headers, deploy a preview and curl -I the asset and an HTML route; confirm the asset shows the one-year immutable value and the HTML shows max-age=0, must-revalidate. (Netlify doesn't expose a simple HIT/MISS cache-status header the way Cloudflare does, so rely on the Cache-Control values and the deploy log rather than a status header.)

Pitfalls & Rollback

  • immutable on HTML: users load an old shell that points at assets that no longer exist → broken pages. Keep HTML revalidated.
  • No must-revalidate on HTML: browsers may serve a stale document indefinitely. Always pair HTML with max-age=0, must-revalidate.
  • Malformed globs: Netlify's parser is strict; a bad rule is silently dropped and the path falls back to defaults. Verify with curl after deploy.
  • Dashboard header rules: prefer _headers (or netlify.toml) so the config is version-controlled and reviewable.
  • Rollback: because the policy lives in a committed _headers file, reverting is a one-line git revert plus a redeploy — no cache state to untangle, since Netlify re-applies headers on the next deploy.

Conclusion

On Netlify the whole task is a correct _headers file: immutable year-long caching for hashed assets, max-age=0, must-revalidate for HTML, and curl -I on a deploy preview to confirm. Get that file right and Netlify's edge does the rest automatically on every deploy. The same two-tier reasoning applies on every host — see Setting Cache-Control Headers on Cloudflare Pages for the Cloudflare syntax.

FAQ

How do I clear Netlify's cache after changing headers?

You don't need to — Netlify purges on each successful deploy. For a quick local check, hard-refresh or append a throwaway query string to bypass the browser cache.

Why are my _headers rules ignored?

Usually a syntax error or wrong location. Keep it in the publish directory, check the deploy log for parse warnings, and verify with curl -I after deploy. A malformed rule is dropped silently and the path falls back to Netlify defaults.

Should I use no-cache or no-store for HTML?

Use no-cache (max-age=0, must-revalidate). no-store bypasses the CDN and raises origin load and TTFB, which defeats the purpose of serving from the edge.

How does Netlify treat immutable?

It honors the directive, so browsers skip conditional revalidation for those hashed assets and serve them straight from cache until the max-age expires.

Static Site Generators in Production