webmasterhelper

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

ResourceCache-ControlWhy
Fingerprinted assets
app.4f2a1c.js, logo.8d31.svg
public, max-age=31536000, immutableThe URL changes when the content does, so it can be cached forever. immutable stops revalidation even on a hard reload.
Unfingerprinted assetspublic, max-age=3600, stale-while-revalidate=86400Short enough to recover from a bad deploy, long enough to help. Fingerprint them instead if you can.
Public HTMLpublic, max-age=0, s-maxage=600, stale-while-revalidate=60Browsers revalidate every time; a CDN serves it from the edge for ten minutes.
Personalised HTMLprivate, no-cacheStorable by the browser but always revalidated, and never by a shared cache.
Anything with a secretno-storeNot written to disk at all. The only directive that really means "do not keep this".

Cache-Control directives

DirectiveMeaning
max-age=NFresh for N seconds, for any cache.
s-maxage=NOverrides max-age for shared caches (CDN, proxy) only. The key to short browser caching with long edge caching.
publicMay be stored by a shared cache even if the request was authenticated.
privateBrowser only. Required for anything user-specific behind a CDN.
no-cacheStore, but always revalidate before use.
no-storeDo not store at all.
must-revalidateOnce stale, never serve it without checking — even if the origin is unreachable.
immutableNever revalidate while fresh, including on reload. Only for fingerprinted URLs.
stale-while-revalidate=NServe the stale copy immediately and refresh in the background for up to N seconds. The single best directive for perceived speed.
stale-if-error=NServe stale content for N seconds when the origin returns an error. Free resilience.
no-transformForbids 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.

HeaderPaired request headerNotes
ETagIf-None-MatchAn 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-ModifiedIf-Modified-SinceOne-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.