Performance · 9 minute read
Cache headers that actually work
Caching is the only optimisation that makes a page faster by doing nothing at all. It is also the one most often configured backwards: long lives for HTML, none for assets.
The rule
| Resource | Cache-Control | Why |
|---|---|---|
| Fingerprinted assets app.4f2a1c.js, logo.8d31.svg | public, max-age=31536000, immutable | The URL changes when the content does, so it can be cached forever. immutable stops revalidation even on a hard reload. |
| Unfingerprinted assets | public, max-age=3600, stale-while-revalidate=86400 | Short enough to recover from a bad deploy, long enough to help. Fingerprint them instead if you can. |
| Public HTML | public, max-age=0, s-maxage=600, stale-while-revalidate=60 | Browsers revalidate every time; a CDN serves it from the edge for ten minutes. |
| Personalised HTML | private, no-cache | Storable by the browser but always revalidated, and never by a shared cache. |
| Anything with a secret | no-store | Not written to disk at all. The only directive that really means "do not keep this". |
Cache-Control directives
| Directive | Meaning |
|---|---|
max-age=N | Fresh for N seconds, for any cache. |
s-maxage=N | Overrides max-age for shared caches (CDN, proxy) only. The key to short browser caching with long edge caching. |
public | May be stored by a shared cache even if the request was authenticated. |
private | Browser only. Required for anything user-specific behind a CDN. |
no-cache | Store, but always revalidate before use. |
no-store | Do not store at all. |
must-revalidate | Once stale, never serve it without checking — even if the origin is unreachable. |
immutable | Never revalidate while fresh, including on reload. Only for fingerprinted URLs. |
stale-while-revalidate=N | Serve the stale copy immediately and refresh in the background for up to N seconds. The single best directive for perceived speed. |
stale-if-error=N | Serve stale content for N seconds when the origin returns an error. Free resilience. |
no-transform | Forbids proxies recompressing images or minifying — occasionally necessary on mobile networks. |
Validators
These turn a 200 into a 304 and save the body, not the round trip.
| Header | Paired request header | Notes |
|---|---|---|
ETag | If-None-Match | An opaque version token. Strong by default; W/"..." marks it weak. Behind a load balancer, make sure every node generates the same ETag for the same bytes — inode-based ETags do not. |
Last-Modified | If-Modified-Since | One-second resolution and awkward for generated content, but free for static files. |
Vary — the header that breaks CDNs
Vary tells caches which request headers change the response. It is required for correctness and expensive if misused: each distinct value of a Vary header is a separate cache entry.
Vary: Accept-Encoding # correct and necessary
Vary: Accept-Encoding, Accept # fine if you negotiate AVIF/WebP
Vary: User-Agent # near-total cache loss: thousands of variants
Vary: Cookie # one entry per cookie value; effectively uncacheable
nginx
# Fingerprinted assets: cache forever.
location ~* "\.[0-9a-f]{8,}\.(css|js|woff2|png|jpg|svg|avif|webp)$" {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable" always;
access_log off;
}
# Other static assets: a modest window.
location ~* \.(css|js|png|jpg|jpeg|gif|svg|webp|avif|woff2|ico)$ {
expires 1h;
add_header Cache-Control "public, max-age=3600, stale-while-revalidate=86400" always;
}
# HTML: let the browser revalidate, let the edge hold it briefly.
location / {
add_header Cache-Control "public, max-age=0, s-maxage=600" always;
try_files $uri $uri/ /index.html;
}
Purging and busting
Fingerprint filenames rather than appending ?v=2. Query strings work, but some intermediate caches historically ignored them, and a changed path is unambiguous everywhere. For HTML, keep the cache window short enough that you never need a purge to recover from a bad deploy — a purge you have to perform under pressure is a design flaw, not a tool.