~ayush.dev
4 min read

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:

  1. Fresh — age is below the freshness lifetime. Served directly, no network.
  2. Stale but usable — freshness has expired, but a directive permits serving it anyway while a background refresh happens.
  3. Stale and unusable — must be revalidated or refetched before it can be served.
a response that uses all three
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.

swr-semantics.ts
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:

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 304 is 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.

ValidatorRevalidationRange requestsGenerated from
ETag: "abc"yesyescontent hash
ETag: W/"abc"yesnoversion, mtime, etc.
Last-Modifiedyes1-second grainfilesystem 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 HTMLprivate, no-cache and rely on an ETag. Stored, but always revalidated.
  • Public API readspublic, max-age=…, stale-while-revalidate=…, plus Vary on whatever actually changes the body.
  • Immutable assetspublic, max-age=31536000, immutable with 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-Language

Every 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 Vary have 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.