HTTP caching and revalidation
HTTP caching has two halves. Freshness decides whether a stored response may be used without asking; Cache-Control sets it. Validation decides what happens when it may not; ETag and Last-Modified let a client ask "has this changed?" and receive a 27-byte 304 Not Modified instead of the whole body.
Freshness: the directives that actually decide#
| Directive | Effect |
|---|---|
max-age=N | The response may be reused without asking for N seconds |
no-cache | Store it, but revalidate before every reuse. It does not mean 'do not cache' |
no-store | Do not write it to disk or memory at all. This is the one that means what people think no-cache means |
must-revalidate | Once stale, a cache may not serve it while it re-checks. Removes the 'serve stale on error' allowance |
private | Only a browser cache may store it, never a shared or CDN cache |
immutable | Promise that the body at this URL will never change. Only safe with a content hash in the URL |
The no-cache misunderstanding causes more production incidents than the rest of the table combined. A response marked no-cache is cached; the cache simply has to check before serving it. That check is cheap when a validator is present, which is why the two mechanisms are designed to be used together rather than chosen between.
Validators: ETag and Last-Modified#
A validator is a token that identifies a specific version of a resource. The client sends it back and the server answers whether it is still current.
GET /assets/app.js HTTP/1.1
If-None-Match: "9f2a1c"
If-Modified-Since: Wed, 10 Sep 2026 08:14:00 GMT
HTTP/1.1 304 Not Modified
ETag: "9f2a1c"
Cache-Control: no-cache, must-revalidateETag- An opaque token. Strong (
"abc") means byte-identical; weak (W/"abc") means semantically equivalent. Derive it from content, not from a build number, or every rebuild invalidates every file. Last-Modified- A timestamp with one-second resolution, which is too coarse for anything that changes rapidly. Also easy to falsify by accident: a redeploy that rewrites files gives every file today's date.
If-None-Match- The conditional request header carrying the ETag. Takes precedence over
If-Modified-Sincewhen both are sent. Vary- Names the request headers that change the response. Omitting
Vary: Accept-Encodingon a compressed response is how a cache serves gzip to a client that did not ask for it.
Does Connect use HTTP caching, and with what headers?#
Used, with a deliberately conservative policy on application assets. Static assets are served with no-cache, must-revalidate. Read against the table above, that says: keep the file, but ask before every use, and do not serve it from the cache while asking.
The reason is a specific failure this policy prevents. The application front end is plain ES modules with no build step — one module imports another by path, and the imports resolve at run time in the browser. If a browser were allowed to reuse a stale module after a deploy, it would assemble a page out of two different releases: a new module importing a symbol an old one no longer exports, or an old module calling an endpoint that has changed shape. The result is a JavaScript error on a screen that works perfectly on a colleague's machine, and it survives a refresh, which is what makes it so expensive to diagnose.
must-revalidate is the half that matters most. Without it, a cache is permitted to serve a stale response while it revalidates in the background — exactly the mixed-release window the policy exists to close.
Where caching goes wrong#
- A long
max-ageon an HTML page. The page is the thing that names every other resource. Cache it hard and you cannot correct anything for the length of the age. immutablewithout a content hash in the URL. The promise is then false, and the only remedy is a URL change you have not planned for.- Caching an authenticated response in a shared cache. Without
privateorno-store, one person's page can be served to another. This is a data-leak class of bug, not a performance one. - **
Vary: *.** Legal, and it makes the response uncacheable everywhere. Usually somebody's attempt to say 'be careful'. - Cache-busting query strings on assets. Some intermediaries ignore the query when keying, so the bust does not always bust.
Questions#
What is the difference between no-cache and no-store?
no-cache allows storage and requires revalidation before each reuse; the body is usually not re-transferred, because the server answers 304. no-store forbids storage entirely, so every request is a full download. Use no-store for responses that must not exist on disk; use no-cache when you want the bandwidth saving without the staleness.
Should a documentation page be cached differently from an application asset?
It can be. A published page changes rarely and a short max-age with a validator is reasonable. An application module is part of a set that must be consistent with the rest of the deploy, and consistency is worth more there than the round trip.
Why does a hard refresh fix a problem that a normal refresh does not?
A normal refresh revalidates the page but may reuse sub-resources according to their own freshness. A hard refresh bypasses the cache for everything. When a hard refresh is the fix, the bug is nearly always an asset with a cache policy that outlived a deploy.