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.
curlavailable 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.
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.
/* 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:
| Scenario | Repeat-visit requests to origin | Repeat-visit load |
|---|---|---|
Netlify defaults (no _headers) | 31 conditional revalidations | 640 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
immutableon HTML: users load an old shell that points at assets that no longer exist → broken pages. Keep HTML onmax-age=0, must-revalidate.- No
must-revalidateon HTML: browsers may serve a stale document indefinitely and never pick up your latest deploy. Always pair HTML withmax-age=0, must-revalidate. - Assets without content hashes: if your generator does not fingerprint filenames, a year-long
immutablecache 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
curlafter every deploy. - Dashboard header rules: prefer
_headers(ornetlify.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 revertplus a redeploy — there is no cache state to untangle, since Netlify re-applies headers and invalidates its edge on the next deploy.
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.
Related
- Parent: CDN Caching Rules for SSGs — the host-agnostic two-tier policy this page implements.
- Setting Cache-Control Headers on Cloudflare Pages — the same policy with Cloudflare syntax.
- Setting Up Deploy Previews on Netlify for Every Pull Request — where you should
curlyour headers before they reach production. - Building an Image CDN Pipeline for Static Sites — cache images under the same long-lived policy.
- Performance Optimization & Core Web Vitals for SSGs — where edge caching fits the TTFB and LCP picture.