Caching: the cheapest performance work, and the easiest to get wrong
No optimisation beats not doing the work. Caching is the highest-leverage performance lever available and the one most often configured by copying headers from a blog post — which is how sites end up serving a signed-in visitor someone else's page, or caching an asset for a year with no way to correct it.
Part ofWeb & Portal Development
A request passes through four or five caches before it reaches your code, and each one obeys different rules. Most caching bugs come from reasoning about one layer while a different layer is the one actually answering.
The layers, in the order a request meets them
Two classes of response, two policies
Almost all of the confusion dissolves once you separate responses whose URL changes when the content changes from responses whose URL stays the same.
The middle case is the interesting one. The zero max-age tells browsers to revalidate on every visit; s-maxage lets the CDN answer from the edge for ten minutes; stale-while-revalidate lets the CDN keep serving the old copy for a day while it fetches a fresh one behind the request. No visitor waits for a regeneration, and the origin sees a fraction of the traffic.
Vary, and how shared caches leak
A CDN keys its cache on the URL. If a response differs by anything other than the URL — a cookie, a language header, a device class — the cache must be told, or it will hand one visitor's response to another.
Vary: Accept-Encodingis almost always correct, and usually handled for youVary: Accept-Languageis correct if you negotiate language, but distinct URLs per locale are better — they are cacheable, linkable and indexableVary: Cookieeffectively disables shared caching, since nearly every visitor has a different value. If a response depends on a cookie, mark itprivateinstead of varying on it- Never
Vary: User-Agent— the cardinality is unbounded and the hit rate collapses to nothing
The failure mode worth fearing is a route that is public for anonymous visitors and personalised once signed in, served under a single cacheable policy. The first signed-in visitor populates the edge with their own page and everyone after them receives it. Split the route, move the personalisation to a client fetch, or mark it private — but do not expect Vary: Cookie to rescue the design.
Invalidate by tag, not by guessing
Time-based expiry is a bet that staleness for some number of seconds is acceptable. Tag-based invalidation removes the bet: when the underlying data changes, everything derived from it is dropped.
Two tags per entity — one specific, one collection-wide — covers the case people forget. Updating a price purges the product page and leaves the category listing quoting the old figure, unless the listing carries the collection tag too.
What should never be cached
- Responses to authenticated requests, unless the cache key includes the identity — and preferably not even then
- Anything carrying a token, a session identifier or a single-use link
- Error responses, beyond a few seconds — a cached 500 turns a transient fault into a sustained outage
- Results of requests that changed state; a POST response is not a cacheable document
- Redirects whose destination depends on the visitor, such as a locale or authentication bounce
The cached-error case deserves the emphasis. If a dependency fails briefly and the edge stores the resulting 503 for ten minutes, the incident outlives its cause by ten minutes on every route that touched it. Give error responses an explicit short policy rather than letting them inherit the success policy.
Measure the hit rate, not the configuration
Caching is one of the few areas where correct-looking configuration and working behaviour diverge silently. The only reliable check is to observe what the edge actually did.
- Request a page twice and read the CDN's cache-status header. A second request still reporting a miss means something is preventing storage — usually a Set-Cookie on the response.
- Confirm content-encoding is present. An uncompressed response is roughly three times the bytes, and some origins compress only when asked.
- Check the hit rate per route rather than in aggregate. A 95% overall figure can conceal a heavily trafficked route sitting at zero.
- Deploy, then verify that hashed assets were not re-requested and that HTML was. That is the whole policy, tested in one observation.
Done properly the result is unglamorous: the origin serves a small share of requests, most visitors are answered from a nearby edge, and a content change appears everywhere within seconds because the purge runs where the write does.
Questions
What Cache-Control header should HTML pages use?
For public pages, "public, max-age=0, s-maxage=<seconds>, stale-while-revalidate=<longer>". That revalidates in browsers on each visit, serves from the CDN edge for the s-maxage window, and keeps serving a slightly stale copy while refreshing behind the request, so no visitor waits for a regeneration. Personalised or authenticated pages should be "private, no-store" instead.
Why is my CDN cache hit rate near zero despite correct headers?
Most often a Set-Cookie header on the response — most CDNs will not store a response that sets a cookie, since doing so would leak it to the next visitor. Session middleware applied to every route, including static pages, is the usual culprit. Vary: Cookie and Vary: User-Agent also collapse hit rates, because they fragment the cache per visitor.
How long can I cache static assets?
A year, with the immutable directive, provided the filename contains a content hash. Because the URL changes whenever the bytes change, a stale copy is unreachable by definition. Never apply that policy to a URL that stays the same when its content changes: the browser cache cannot be purged, so there would be no way to correct it.
Is time-based expiry or tag-based invalidation better?
Tag-based, wherever the write path is under your control. Time-based expiry is a bet that some number of seconds of staleness is acceptable; tags remove the bet by purging exactly what depended on the changed data. Tag each entity both specifically and by collection, so updating one record also refreshes the listings containing it, and run the purge from the mutation itself rather than a scheduled job.