Cache-Control for static sites, without the folklore
Immutable assets, revalidated HTML and the one mistake that serves visitors a page whose CSS no longer exists.
Caching headers are the kind of topic where everyone has a snippet they copied from somewhere in 2014. I did too. Then a deploy left half my readers with an unstyled page for a day, and I actually sat down with RFC 9111. This is what I took away, specifically for static sites built by tools like Astro, Vite or Eleventy.
Two kinds of files
A static build produces two kinds of files, and they want opposite treatment.
Fingerprinted assets. Your bundler writes index.B3kd9aZ_.css instead of index.css. The hash in the name is derived from the content. If the content changes, the name changes. So the file behind a given URL will never change. That’s the whole trick, and it means you can let browsers and CDNs keep it for as long as they like.
Everything with a stable URL. HTML pages, robots.txt, feeds, favicon.svg, Open Graph images. /about/ will always be /about/, but its content changes whenever you deploy. These must not be cached blindly.
The mistake is treating them the same way, in either direction.
The headers you actually need
For fingerprinted assets:
Cache-Control: public, max-age=31536000, immutable
max-age=31536000 is one year in seconds; nothing reasonable needs longer. immutable tells the browser not to revalidate even when the user hits reload. Without it, Chrome and Firefox happily send conditional requests for every asset on reload, get a 304 back, and waste a round-trip per file.
For HTML:
Cache-Control: public, max-age=0, must-revalidate
This does not mean “don’t cache”. The browser stores the page, but it has to ask the server “still current?” before using it. If you send an ETag or Last-Modified (static file servers do this automatically), the answer is a tiny 304 Not Modified. You get correctness on every visit and almost the bandwidth savings of a real cache.
You’ll see no-cache recommended for the same purpose, and it’s nearly equivalent: no-cache also means “store, but revalidate before use”. no-store is the one that actually forbids storing, and you almost never want it for public pages because it also kills the back/forward cache.
The failure mode, step by step
Here’s what happened to me. I had a blanket rule: cache everything for a week.
- Monday, a reader loads
/posts/foo/. The HTML references/_astro/index.AAAA.css. Both get cached for a week. - Wednesday, I deploy. The new build writes
index.BBBB.css, andrsync --deleteremovesindex.AAAA.cssfrom the server. - Thursday, the reader opens a different page,
/about/, for the first time. That HTML is fresh and referencesindex.BBBB.css. Fine. - They go back to
/posts/foo/. The browser uses its cached HTML, which still saysindex.AAAA.css. The CSS was also cached, so it still works… until the browser evicts it under memory pressure, re-requests it, and gets a404.
Result: an unstyled page, a week after I thought the deploy was done, on one reader’s phone. Unreproducible on my machine, of course.
The fix is exactly the split above. HTML revalidates, so step 4 fetches the new HTML with the new asset names. Assets are immutable, so nothing is lost.
Writing it in .htaccess
On Apache or LiteSpeed, I set a default for everything and override it for files whose names carry a hash:
<IfModule mod_headers.c>
Header set Cache-Control "public, max-age=0, must-revalidate"
<FilesMatch "\.[A-Za-z0-9_-]{8}\.(css|js|woff2?|svg|png|jpe?g|webp|avif)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
The regex matches “a dot, eight URL-safe characters, a dot, an extension”, which is how Vite (and therefore Astro) names hashed files by default. Check your own build output: if your tool uses a different hash length, adjust the {8}. Matching by extension alone, like many snippets do, is the trap. favicon.svg and your OG images would get a year of caching despite having stable names.
For stable-named images I use a middle ground, one day:
<FilesMatch "^(favicon\.svg|apple-touch-icon\.png)$">
Header set Cache-Control "public, max-age=86400"
</FilesMatch>
Note that FilesMatch matches the file name, not the path. You can’t write ^/_astro/ in there. That’s why the hash pattern is the more robust selector anyway.
Verify, don’t assume
The number one reason caching rules “don’t work” is that they never applied: a typo in the regex, mod_headers not enabled, or a .htaccess that didn’t get deployed because it’s a dotfile and some upload tool skipped it. Check the real response:
curl -sI https://example.ch/ | grep -i -E 'cache-control|etag|last-modified'
curl -sI https://example.ch/_astro/index.B3kd9aZ_.css | grep -i cache-control
Then check a conditional request really gets a 304:
etag=$(curl -sI https://example.ch/ | awk -F': ' 'tolower($1)=="etag"{print $2}' | tr -d '\r')
curl -s -o /dev/null -w '%{http_code}\n' -H "If-None-Match: $etag" https://example.ch/
If that prints 304, your HTML revalidation costs a few hundred bytes per visit.
What about CDNs?
If there’s a CDN in front of your host, it reads the same header, with one addition worth knowing: s-maxage applies only to shared caches. You can let the CDN hold HTML for a minute while browsers still revalidate every time:
Cache-Control: public, max-age=0, s-maxage=60, must-revalidate
Just remember to purge the CDN on deploy, or accept up to 60 seconds of staleness. On plain shared hosting without a CDN, you don’t need it.
The checklist
- Hashed file names get
max-age=31536000, immutable. - Everything else gets
max-age=0, must-revalidate(or a shortmax-agefor icons). - Never match by extension alone.
- Ship the rules with the build so they can’t drift from the server.
- Verify with
curl -sIafter every change to the rules.
That’s it. No folklore, no Expires headers from 1999, no Pragma: no-cache. Two rules and a regex.