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 page is the Netlify-specific recipe within the host-agnostic CDN Caching Rules for SSGs, which sits inside the broader Performance Optimization & Core Web Vitals for SSGs work. If you deploy elsewhere too, the Cloudflare Pages version applies the identical policy with different syntax.

Get the header policy wrong and repeat visitors pay for it: either the browser re-downloads or re-validates every asset it already has (slow), or it serves a stale HTML shell that points at hashed files that no longer exist (broken). The fix is a short, committed config file and a curl check after deploy.

Prerequisites

  • A site already deploying to Netlify from Astro, Hugo, Eleventy, or Jekyll — all four emit content-hashed asset filenames by default.
  • Access to the repository, so the header config is version-controlled rather than clicked into the dashboard.
  • curl available locally to inspect response headers against the deployed URL.
  • A rough idea of your asset output paths (e.g. Astro's /_astro/, Hugo's /, an /assets/ folder), so your globs actually match the files that ship.
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.

The _headers File

Create a file named _headers (no extension) at the root of your publish directory. If you keep it in the project root instead, Netlify copies it into the publish directory during the deploy, so either location works as long as it lands in the deployed output. Long-cache the hashed assets, revalidate the 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
/*.woff2
  Cache-Control: public, max-age=31536000, immutable

The first rule is a safe default for everything (HTML included): revalidate before serving. The following rules override it for content-addressed files — anything with a build hash in its name — pushing those to a year with immutable. Add a line for whatever asset folder your generator uses; Astro ships hashed files under /_astro/*, so add that path, while Hugo's fingerprinted assets land wherever your pipeline writes them.

This works because Astro, Hugo, Eleventy, and Jekyll all emit content-hashed asset filenames — a new build produces a new URL, so caching the old one forever is safe. Make sure your build actually fingerprints assets (it is on by default in Astro and available via resources.Fingerprint/asset pipelines in Hugo); without hashes, immutable will happily serve last week's stylesheet.

The netlify.toml alternative

If you already keep build config in netlify.toml, you can express the same policy there instead — the rules live beside your build command and are equally version-controlled:

[[headers]]
  for = "/*"
  [headers.values]
    Cache-Control = "public, max-age=0, must-revalidate"

[[headers]]
  for = "/assets/*"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"

Pick one file, not both. Defining the same path in _headers and netlify.toml makes the effective header order-dependent and hard to audit — the one thing you want a cache policy never to be.

Rule ordering and specificity

Netlify applies matching rules in file order, and later, more specific rules override earlier ones for the paths they match. That is exactly why the broad /* HTML default comes first and the narrow asset globs come after: a request for /_astro/app.abc123.js matches both /* and /*.js, and the more specific asset rule wins. Keep your catch-all at the top and your asset overrides below it.

How Netlify resolves a request against the _headers rule list A request for the hashed asset /_astro/app.abc123.js is evaluated against four rules top to bottom. The broad slash-star rule matches first and sets max-age zero, must-revalidate, but is superseded. The /assets/ rule does not match. The /*.js rule matches and, being later and more specific, wins with max-age 31536000 immutable. The /*.css rule does not match. The effective response header is the immutable one-year value from the winning rule. How one request resolves its Cache-Control header REQUEST /_astro/app.abc123.js a content-hashed JS asset reads top to bottom /* public, max-age=0, must-revalidate matched, then overridden /assets/* public, max-age=31536000, immutable path does not match /*.js public, max-age=31536000, immutable match — later & specific, wins /*.css public, max-age=31536000, immutable path does not match EFFECTIVE Cache-Control ON THE RESPONSE public, max-age=31536000, immutable
Netlify reads the rules top to bottom; a hashed asset matches both the broad /* default and the specific /*.js override, and the later, more specific rule wins — so the response ships with the one-year immutable value.

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 (and Netlify's edge) revalidates with the origin before serving, but the response is still cacheable, so a conditional 304 Not Modified is cheap and the CDN keeps doing useful work.

Avoid no-store. It bypasses the CDN entirely and forces a full origin fetch on every request, inflating Time to First Byte — the opposite of why you deploy to an edge network in the first place. no-store also disables the browser's back/forward cache benefits for that document. Reach for it only when a response genuinely must never be written to any cache (authenticated, per-user pages), which a statically generated marketing or docs site does not have.

Diagnosing and Validating

Local dev does not fully reproduce edge headers, so inspect what Netlify actually sends against the deployed URL. Deploy a preview and curl -I both an asset and an HTML route:

# hashed asset — expect the one-year immutable value
curl -I https://your-site.netlify.app/assets/app.abc123.js | grep -i cache-control

# an HTML route — expect max-age=0, must-revalidate
curl -I https://your-site.netlify.app/guide/ | grep -i cache-control

Confirm each Cache-Control value matches your intent, then check the deploy log for _headers parse warnings — Netlify's parser is strict, and a malformed rule is dropped silently while the path falls back to Netlify's defaults. Netlify does not expose a simple HIT/MISS cache-status header the way some CDNs do, so rely on the Cache-Control values and the deploy log rather than a status header. Netlify automatically invalidates its edge cache on each successful deploy, so there are no manual purges to run.

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, measured with the Chrome DevTools Network panel on a throttled Fast 3G profile:

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 browser has no cached copies either way. The win is entirely on repeat navigation, where immutable lets the browser skip revalidation for every hashed asset and issue a single conditional request for the HTML. That saved round-trip budget is what keeps repeat-visit LCP low. If your pages are image-heavy, pairing these headers with a dedicated image CDN pipeline compounds the effect, since those responses cache under the same long-lived policy.

Pitfalls & Rollback

  • immutable on HTML: users load an old shell that points at assets that no longer exist → broken pages. Keep HTML on max-age=0, must-revalidate.
  • No must-revalidate on HTML: browsers may serve a stale document indefinitely and never pick up your latest deploy. Always pair HTML with max-age=0, must-revalidate.
  • Assets without content hashes: if your generator does not fingerprint filenames, a year-long immutable cache will serve stale CSS/JS after a deploy. Fix the build to fingerprint before you extend cache lifetimes.
  • Globs that miss the real asset path: a rule for /assets/* does nothing if Astro writes to /_astro/. Verify the actual output path and glob it explicitly.
  • Malformed rules: Netlify's parser is strict; a bad line is silently dropped and the path falls back to defaults. Confirm with curl after every deploy.
  • Dashboard header rules: prefer _headers (or netlify.toml) so the config is reviewed in pull requests, not changed invisibly in the UI. This pairs well with running the check on deploy previews for every pull request.
  • Rollback: because the policy lives in a committed file, reverting is a one-line git revert plus a redeploy — there is no cache state to untangle, since Netlify re-applies headers and invalidates its edge on the next deploy.
What each file type should be told Three panels of file types and their policy: hashed build assets get a year and immutable, HTML gets revalidation, and unhashed public files get a short shared max-age. Each panel lists the failure mode when the policy is wrong. What each file type should be told Hashed assets max-age=31536000, immutable never revalidated, never purged filename is the version wrong policy → needless revalidation HTML max-age=0, must-revalidate 304 on unchanged pages always current after a deploy wrong policy → stale pages for hours Unhashed public files short max-age, longer s-maxage purge these specifically on change favicon, og images, downloads wrong policy → stale logo for a year Write the policy as three rules in _headers rather than as exceptions per path.
The third panel is where mistakes hide, because those files change rarely and nobody notices the policy until one of them does.

Conclusion

On Netlify the whole task is a correct header file: immutable year-long caching for content-hashed assets, max-age=0, must-revalidate for HTML, and a curl -I on a deploy preview to confirm both. Put the catch-all revalidation rule first, layer the asset overrides below it, match your generator's real output paths, 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, or the parent CDN Caching Rules for SSGs for the reasoning behind the split.

FAQ

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

You don't need to — Netlify purges its edge 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.

Should I use _headers or netlify.toml?

Either works and both are version-controlled. Use _headers for a plain, path-first rule list; use netlify.toml [[headers]] blocks when you already keep other config there or want the rules beside your build settings. Do not define the same path in both — overlapping rules become order-dependent and hard to reason about.