The fastest request is the one that is never made
A cached response costs nothing: no DNS, no connection, no round trip. Caching is the largest performance win available to a web page, and it is entirely controlled by a header you write.
There are two questions, and they are separate. May this be stored? And how long is it good for? no-store answers the first. max-age answers the second. Confusing them is how a page ends up either uncacheable or wrong.
Cache-Control, in the directives that matter
| Directive | Means |
|---|---|
no-store | never write this down anywhere — for personal or secret data |
no-cache | store it, but always ask before reusing it (a revalidation, not a full download) |
max-age=3600 | fresh for an hour; after that it must be revalidated |
immutable | while fresh, never ask again — even on a reload |
must-revalidate | once stale, never serve it while offline |
private | a browser may store it, a shared CDN may not |
public | a shared cache may store it even if it looks personal |
s-maxage=60 | a different lifetime for shared caches (CDNs) |
It means "do not use it without asking". The one that forbids storing is no-store. Half the misconfigured sites on the web have this pair the wrong way round.
The two patterns that cover almost everything
A content hash in the file name turns caching into a solved problem: the URL is a promise that the bytes never change, so it can be cached for a year, and a deploy simply references a different URL. Every bundler does this by default.
The document that points at those assets must not be cached hard, or a visitor keeps the old HTML and never learns the new asset names. no-cache gives you a revalidation — usually a 304 with no body, which is a few hundred bytes.
# /assets/app.8f3d21.css — the name changes when the content does
Cache-Control: public, max-age=31536000, immutable
# /index.html — must always be current, but need not always be re-downloaded
Cache-Control: no-cache
# /api/me — personal
Cache-Control: no-store
Where things are cached
A CDN purge does nothing to the copy in a browser. That is why a hard cache lifetime is a promise you cannot take back, and why hashed file names matter more than any purge button.
| Cache | Sees | Cleared by |
|---|---|---|
| the browser | everything for that user | the user, or an expiry |
| a CDN / shared proxy | everything not marked private | your purge command |
| a service worker | whatever your code puts there | your code — see that lesson |
Vary: the cache key
A cache stores one entry per URL plus the request headers named in Vary. Forgetting it serves the wrong language to somebody; Vary: Cookie on a public page makes every logged-in user their own cache entry and throws the hit rate away.
Vary: Accept-Encoding # the compressed and uncompressed copies differ
Vary: Accept-Language # …and the Azerbaijani and English ones
Vary: Cookie # effectively uncacheable in a shared cache