stale-while-revalidate and the shape of a good cache header
max-age is a blunt instrument. The interesting behaviour lives in the freshness lifetime, the stale window, and who is allowed to serve what during a revalidation.
- http
- caching
- performance
Almost every cache bug I've debugged came from treating Cache-Control as a single number. It isn't. A response has a freshness lifetime, then a stale period with its own rules, and different caches along the path are allowed to do different things in each phase.
The three phases of a cached response
From the moment a response is stored, it moves through three states:
- Fresh — age is below the freshness lifetime. Served directly, no network.
- Stale but usable — freshness has expired, but a directive permits serving it anyway while a background refresh happens.
- Stale and unusable — must be revalidated or refetched before it can be served.
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=60, stale-while-revalidate=600, stale-if-error=86400
ETag: "a1b2c3"Read that as: serve from cache for 60 seconds; for the next 10 minutes serve the stale copy immediately and refresh in the background; and if the origin is down, keep serving the stale copy for up to a day.
The three numbers are independent budgets, and they answer three different questions.
max-age: how wrong am I willing to be?
max-age is not "how long to cache." It's the window in which you accept serving data without checking. Setting it to zero doesn't disable caching — it just means every request revalidates.
stale-while-revalidate: who eats the latency?
Without it, the request that arrives after expiry pays the full origin round trip. With it, that request gets the stale copy instantly and the refresh happens out of band.
function serve(req: Request, entry: CacheEntry): Response {
const age = now() - entry.storedAt;
if (age <= entry.maxAge) {
return entry.response; // fresh
}
if (age <= entry.maxAge + entry.staleWhileRevalidate) {
// The key move: respond *now*, refresh *after*. The user never
// waits on the origin, and only one refresh is in flight
// regardless of how many requests land in this window.
scheduleRevalidation(req, entry);
return withWarning(entry.response);
}
return revalidateBlocking(req, entry);
}The thing worth internalising: stale-while-revalidate converts a latency problem into a staleness problem. You're trading correctness-in-the-moment for a flat response time. That's the right trade for a product listing and the wrong one for an account balance.
stale-if-error: what happens when the origin is gone?
This is the cheapest availability win in HTTP and the most commonly omitted directive. A long stale-if-error means an origin outage degrades to stale content instead of a 5xx.
Revalidation is not refetching
When a cache does revalidate, it shouldn't be re-downloading the body. It sends a conditional request:
GET /api/products HTTP/1.1
If-None-Match: "a1b2c3"If nothing changed, the origin replies 304 Not Modified with no body, and the cache resets the entry's age. You keep the bytes you already had.
A
304is not a cache miss. It's a successful cache hit that cost one round trip and zero bytes of body. Treating them as misses in your dashboards will make your hit rate look far worse than it is.
Strong vs weak validators
An ETag prefixed with W/ is a weak validator: it says two representations are semantically equivalent, not byte-identical. That's fine for revalidation and not fine for range requests, which need byte-exact identity to stitch a partial response together.
| Validator | Revalidation | Range requests | Generated from |
|---|---|---|---|
ETag: "abc" | yes | yes | content hash |
ETag: W/"abc" | yes | no | version, mtime, etc. |
Last-Modified | yes | 1-second grain | filesystem timestamp |
private, public, and the shared-cache trap
Cache-Control: private means only the browser may store this — CDNs and proxies must not. Omitting it on a personalised response is how one user's dashboard ends up served to another.
The safe defaults:
- Personalised HTML —
private, no-cacheand rely on anETag. Stored, but always revalidated. - Public API reads —
public, max-age=…, stale-while-revalidate=…, plusVaryon whatever actually changes the body. - Immutable assets —
public, max-age=31536000, immutablewith a content hash in the filename.
That last one is worth being precise about. immutable tells the browser not to revalidate even on a manual reload. It's only correct when the URL itself changes whenever the content does — which is exactly what a hashed filename guarantees.
Vary is part of the cache key
Cache-Control: public, max-age=300
Vary: Accept-Encoding, Accept-LanguageEvery header named in Vary multiplies the number of stored variants. Vary: Accept-Encoding is nearly free. Vary: User-Agent shatters your hit rate into thousands of near-identical entries and is almost never what you meant.
A checklist that catches most of it
- Is this response personalised? If yes,
private— no exceptions. - Would serving 30-second-old data hurt anyone? If no, you want
stale-while-revalidate. - Would serving day-old data during an outage beat a 500? Almost always yes — set
stale-if-error. - Does the URL change when the content changes? If yes, cache it for a year and mark it
immutable. - Does anything in
Varyhave high cardinality? If so, you have a cache with a very low hit rate wearing a costume.
Get those five right and the rest of HTTP caching is detail.