Cache-Control Is the Config File You Never Wrote

There are two ways a static site gets caching wrong, and they look nothing alike.
The first: you deploy, and every returning visitor re-downloads 180 KB of JavaScript, CSS, and WebP that did not change, because the host slapped a conservative default on every path and nobody looked. The site is fast on Lighthouse and mediocre in production, which is exactly backwards from what everyone assumes.
The second: someone read a performance article, set max-age=31536000 on everything, and shipped a bug. The fix goes out in four minutes. The broken HTML stays pinned in browsers for a year, and there is no purge button that reaches into a laptop in Ohio.
Both come from the same root cause: caching policy is the one piece of your build output nobody wrote down.
The two-tier rule
Every file your build produces falls into exactly one of two buckets, and the bucket is determined by a single question: does the filename change when the contents change?
- Fingerprinted — the filename contains a content hash.
client.ChYG-O0T.js. A new build with different bytes produces a different name. Cache it forever. - Stable-named — the filename stays put across deploys.
index.html,/about/index.html,favicon.ico. Never let a browser cache it. Let the CDN cache it hard and purge on deploy.
That is the whole model. Everything else is edge cases around it.
The reason this works is that fingerprinted assets are never invalidated — they are abandoned. A build that changes client.js emits a new hash, the HTML points at the new hash, and the old file is simply never requested again. There is no coherency problem to solve, so there is no reason to ever revalidate.
Astro puts these in _astro/. Next.js puts them in /_next/static/. Vite hashes everything that passes through the bundler. If you use any of them, tier one already exists in your output and you are probably not exploiting it.
Tier one: fingerprinted assets
Cache-Control: public, max-age=31536000, immutable
A year, and no revalidation. immutable is the part people skip, and it earns its place: it tells the browser not to send a conditional request even on an explicit reload. Without it, a user hitting refresh fires an If-None-Match for every asset on the page and eats a round trip per file to be told nothing changed.
Firefox and Safari honour immutable. Chrome ignores the directive but changed its reload semantics years ago to stop revalidating subresources anyway, so you land in roughly the same place. Set it regardless — it costs nothing and it is doing real work on two of the three engines.
One caveat worth internalising: a 304 is not free. People see “304 Not Modified” in devtools, note the tiny response, and conclude the cache is working. It saved you the bytes. It did not save you the connection, the request, the server round trip, or the head-of-line time before the browser can lay out the page. On a mobile connection with 120 ms of latency, thirty revalidations is thirty round trips you did not need to make.
Tier two: HTML
Cache-Control: public, no-cache, s-maxage=86400, stale-while-revalidate=604800
Read that as three separate instructions to three separate caches:
no-cache— browsers may store the response but must revalidate before using it. This is notno-store; the bytes stay on disk and a 304 makes the revalidation cheap. It is the correct setting for any document that can change under a stable URL.s-maxage=86400— shared caches (your CDN) may serve it for a day without asking your origin. This is the line that actually makes your site fast, and it is the one most people never set.stale-while-revalidate=604800— if the edge copy is stale, serve it immediately and refresh in the background. Nobody waits on your origin.
The obvious objection: if the CDN holds HTML for a day, how does a deploy go out? You purge. Every serious host does this automatically on deploy — that is the entire point of atomic static deploys. If yours does not, add a purge call to the end of your deploy pipeline. That is a five-line script, not an architecture.
The pattern is: aggressive shared caching plus explicit invalidation. It is strictly better than short TTLs, because short TTLs mean you are always slightly stale and always hitting origin. Purge-on-deploy means you are never stale and almost never hitting origin.
If you cannot purge, drop
s-maxageto something you can live with — 60 seconds still absorbs a traffic spike, and the failure mode is one minute of staleness instead of a day.
The trap: everything you hand-placed
Here is where real sites break. Your bundler fingerprints what passes through it. It does not fingerprint what you drop in public/.
Take a typical Astro output directory:
dist/
_astro/ ← fingerprinted. tier one.
index.html ← stable name. tier two.
posts/…/index.html
favicon.ico ← stable name, copied verbatim
og-image.png ← stable name, copied verbatim
fonts/ ← stable names, copied verbatim
media/ ← stable names, copied verbatim
feed.xml
sitemap-index.xml
pagefind/
Everything under public/ lands in the output with the name you gave it. A year-long immutable header on /media/* means your hero images are frozen — which is fine, until you re-export one at better quality under the same filename and it never reaches anyone who has visited before.
Three specific landmines:
Self-hosted fonts. They are the ideal long-cache candidate — large, and they genuinely never change. But they are unhashed. Either put a version in the path (/fonts/v2/inter.woff2) and cache for a year, or leave them at a few weeks and accept the occasional revalidation.
Search indexes. Pagefind writes a stable pagefind-entry.json that points at content-hashed index chunks. Cache the chunks forever; cache the entry manifest like HTML. Invert that and search silently breaks after a deploy, pointing at chunks that no longer exist — and it breaks only for returning visitors, which is the worst possible bug shape.
Feeds and sitemaps. feed.xml and sitemap-index.xml change on every publish under a fixed URL. An hour is generous:
- path: /feed.xml
name: Cache-Control
value: 'public, max-age=3600, s-maxage=3600'
The general rule: any stable filename whose contents change is tier two, no matter what kind of file it is.
Writing it down
Caching policy belongs in the repo, next to your security headers, reviewed in the same PR as the code that depends on it.
Render, in render.yaml:
headers:
- path: /_astro/*
name: Cache-Control
value: 'public, max-age=31536000, immutable'
- path: /*.html
name: Cache-Control
value: 'public, no-cache'
Netlify and Cloudflare Pages, in public/_headers:
/_astro/*
Cache-Control: public, max-age=31536000, immutable
/*
Cache-Control: public, no-cache
Nginx, if you are hosting it yourself:
location /_astro/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
add_header Cache-Control "public, no-cache";
}
Order matters. Most of these engines apply the most specific match, but “most” is doing work in that sentence — put the broad /* rule last and verify the result rather than trusting the docs.
Verify it, because you will be wrong
Assume nothing. Ask the server:
curl -sI https://example.com/ | grep -i 'cache-control\|age\|etag'
curl -sI https://example.com/_astro/client.ChYG-O0T.js | grep -i 'cache-control'
Then check that the edge is actually holding it. Request the same URL twice and watch for a non-zero age, plus whatever hit/miss header your CDN emits (cf-cache-status, x-cache, x-vercel-cache). An age that resets to zero on every request means your s-maxage is not taking effect and every visitor is paying for a trip to your origin.
Do this after a deploy too. The interesting failure is not “the header is missing” — it is “the header is right and the CDN ignored it.”
Three things that quietly break caching
Vary: Cookie. If anything in your stack sets a cookie on static responses — an analytics script proxied through your own domain, a preview-mode flag, an over-eager middleware — and the response carries Vary: Cookie, your shared cache fragments per user and your hit rate collapses. On a static site, the only correct Vary is Accept-Encoding, and your CDN handles that itself.
Query-string cache busting. app.js?v=3 is a workaround from before bundlers hashed filenames. Some intermediary caches treat query strings as uncacheable, CDN key normalisation can strip them, and you get a stable URL pretending to be a versioned one. Hash the path, not the query.
Trusting ETags alone. ETags are a fine backstop and a terrible strategy. They optimise the response while leaving the request fully intact. If your entire caching plan is “the origin sends ETags,” you have built a site that makes a network round trip for every asset on every page load, and you will never see it in a cold-load Lighthouse run.
The takeaway
Two lines of config, in the repo, reviewed like code:
- Fingerprinted paths →
public, max-age=31536000, immutable - Everything else →
public, no-cachefor the browser, a longs-maxagefor the edge, purge on deploy
Then go read your own headers with curl, because there is a very good chance the site you are proudest of is currently telling every returning visitor to check back with the origin on every single file.
Static hosting made the bytes cheap. Cache headers are how you stop sending them twice.
