HTTP Caching Headers That Actually Behave
Caching bugs have a particular flavour: everything works on your machine, then a customer reports that yesterday’s price is still showing. Most of the confusion traces back to a handful of directives that look similar but behave differently. Here is a practical map of what each one does, which combinations to use for which kind of file, and how to find out what your CDN is really doing.
Two questions every cache asks
A cache needs to know first whether its stored copy is still fresh, and second, if it is stale, whether anything has actually changed. Freshness is controlled by lifetime directives such as max-age. Change detection is handled by validators: an ETag or Last-Modified value the client sends back in a conditional request. If nothing changed, the server replies with 304 Not Modified and no body, and the stored copy becomes fresh again.
The directives that get confused
no-cachedoes not mean “do not cache”. The response may be stored, but it must be revalidated with the origin each time before reuse.no-storeis the one that prevents storage. MDN notes it is not always the best choice, because it also forfeits benefits such as the back/forward cache.privatekeeps a response out of shared caches such as CDNs. A cookie by itself does not make a response private.s-maxageapplies only to shared caches and overridesmax-agethere, so browsers and CDNs can hold different lifetimes.immutablepromises the file will never change at that URL, which is only safe when the URL contains a version or hash.
Avoid relying on heuristic caching. Without explicit headers, caches may still store a response that has a Last-Modified date and reuse it for a fraction of its apparent age, which makes behaviour hard to predict.
A header set per kind of file
Give each class of resource its own policy:
- Hashed static assets (
app.3f9c1a.js, fonts, images with a content hash):public, max-age=31536000, immutable. Changing the file changes the name, so the old entry is simply never asked for again. - HTML pages:
no-cache, so every visit revalidates and picks up new asset names. Addprivateif the page is personalised. - Semi-fresh API or catalogue data: a short
max-agewith stale-while-revalidate. - Anything involving login, sessions or payment:
no-store.
location ~* \.(?:js|css|woff2|avif|webp|png|jpg)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location = / {
add_header Cache-Control "no-cache";
}
location /api/products {
add_header Cache-Control "public, max-age=60, stale-while-revalidate=300";
}
The first block assumes your build tool fingerprints filenames. If it does not, do not use a year-long lifetime; the cache cannot know when a file changes.
What stale-while-revalidate does
This directive lets a cache hand over an expired copy instantly while it fetches a fresh one in the background. DebugBear’s explanation uses three states: fresh, stale and too old to serve. The max-age value marks the end of fresh, and the stale-while-revalidate value extends how long after that the stale copy may still be served. With max-age=60, stale-while-revalidate=300, requests in the first 60 seconds are served straight from cache, requests between 60 and 360 seconds get the stale copy plus a background refresh, and later requests wait for the origin.
It suits content that can be a little behind, such as catalogues, documentation and blog pages. It is a poor fit for stock levels, reservations, login state or anything that has to be current. A companion directive, stale-if-error, serves a stored copy while the origin is failing.
Vary and the cache hit rate
Caches key on the URL by default. Adding a header name to Vary splits the entry per value of that header. Vary: Accept-Language keeps English and Japanese pages apart, which is what you want. Vary: User-Agent creates so many variants that shared caching is effectively defeated, so feature-detect instead. For personalised content, private is usually a better answer than varying on cookies.
Debugging a CDN that keeps missing
Compare what the origin sends with what the edge returns, since some CDNs add or strip headers:
curl -sI https://origin.example.com/styles.css | grep -i -E 'cache-control|etag|age'
curl -sI https://www.example.com/styles.css | grep -i -E 'cache-control|etag|age|cf-cache-status|x-cache'
Provider-specific indicators include CF-Cache-Status on Cloudflare and X-Cache on Fastly and CloudFront, and the Age header shows how long a response has been stored. A response that misses every time frequently has Cache-Control: private, a Set-Cookie header or an Authorization header in play.
One more gotcha: purging the CDN does not clear anyone’s browser. Visitors keep their copy until max-age runs out, which is why hashed filenames beat aggressive purging.
If your storefront or WordPress site needs a caching review, our services cover CDN setup and performance audits.