← pllu.net

1. The cast: browser, shared cache, origin

Every HTTP response can be cached in two kinds of place: a private cache (the browser, on your laptop) and a shared cache (a CDN or proxy, in front of the origin). Different directives talk to different caches.

Browser (private cache) CDN / proxy (shared cache) Origin server
Browser private cache CDN / proxy shared cache Origin server of truth GET /thing on miss 200 + Cache-Control 200 + Cache-Control Who reads which directive? private: only the browser may store. Useful for per-user pages. public / s-maxage: invitation to the CDN. Drops in front of origin and serves many users from one stored copy. no-store: nobody stores, anywhere. The whole flight goes to origin every time. no-cache: store, but always revalidate with the origin before serving. (It is NOT "don't cache".) immutable: promise the body will never change for this URL. Browser skips revalidation even on reload.

2. The freshness lifetime: fresh → stale → gone

A cached response moves through three states. The clock starts the moment the response is stored. The length of the fresh window is set by max-age (private) or s-maxage (shared). After that the response is stale, usable only with extra rules (stale-while-revalidate, stale-if-error) or after a successful revalidation.

FRESH: serve from cache, no questions STALE: must revalidate (or use SWR) t = 0 (stored) t = max-age t = max-age + stale-while-revalidate Lifetime of a stored response Age is measured from the response's Date header, or when the cache first stored it.

Fresh response

Returned instantly from the cache. Age response header tells you how old the copy is.

HTTP/1.1 200 OK
Cache-Control: max-age=600
Age: 142
ETag: "v3"

Stale response (conditional GET)

Cache asks origin "still good?" with If-None-Match. Origin replies 304 (no body, just "yes") and the cache resets the freshness clock.

GET /thing
If-None-Match: "v3"

HTTP/1.1 304 Not Modified

3. Every directive, what it does, who reads it

Cache-Control is the modern way to talk to caches. Multiple directives are comma-separated. Most are response directives (server → cache); a few are request directives (client → cache). Here are all of the standard ones.

Response directives

DirectiveWhoWhat it does
max-age=Nall cachesFresh for N seconds from the response's date.
s-maxage=Nshared onlyLike max-age, but only for shared caches (CDN/proxy). Overrides max-age there.
publicall cachesExplicitly permits storage in shared caches, even for normally-private responses (e.g. ones with Authorization).
privatebrowserOnly the browser may store it. Forbids CDN/proxy storage.
no-cacheall cachesYou may store, but must revalidate with origin before reuse. Despite the name, it does NOT disable caching.
no-storeall cachesDo not store. The hardest directive: every request goes through.
must-revalidateall cachesOnce stale, you may not serve the cached copy on origin failure: return 504 instead. Closes a loophole in max-age.
proxy-revalidateshared onlySame as must-revalidate, but only binds shared caches.
immutablebrowserBody will never change at this URL. Browser skips revalidation even when the user hits reload.
stale-while-revalidate=Nall cachesFor up to N seconds after going stale, serve the stale copy now and revalidate in the background.
stale-if-error=Nall cachesFor up to N seconds after going stale, serve the stale copy if the origin is broken (5xx or unreachable).
no-transformall cachesForbid intermediaries from changing the body (no image re-compression, no minification).

Request directives

DirectiveWhat it does
max-age=NClient only wants responses no older than N.
max-stale[=N]Client tolerates a stale response (optionally up to N seconds past expiry).
min-fresh=NClient wants a response that will still be fresh for at least N more seconds.
no-cacheForce every cache between client and origin to revalidate.
no-storeDon't store the request or any response to it. Useful for sensitive forms.
only-if-cachedReturn a cached copy or 504. Never hit the network. (Mostly used by offline tooling.)
Companion response headers you should also know
  • Age: seconds since this response was generated by origin (added by shared caches).
  • Date: when origin produced the response.
  • Expires: HTTP/1.0 absolute time of expiry. Ignored if Cache-Control: max-age is present.
  • Vary: list of request headers that vary the response. Without it, a cache can serve the wrong variant (wrong language, wrong encoding) to the wrong user.
  • ETag / Last-Modified: validators used for conditional GETs (see next section).

4. Validators: ETag and Last-Modified

When a stored response goes stale, the cache doesn't have to throw it away and re-download the body. Instead it asks the origin "is the version I have still good?" using one of two validators.

Browser CDN Origin GET /thing stale GET /thing If-None-Match: "v3" 304 Not Modified 200 OK (body from cache) The body of the response never re-crosses the wire. Only the small request and a 14-byte 304.

ETag (strong validator)

An opaque tag the origin assigns to a specific representation. Sent back on the next request as If-None-Match.

HTTP/1.1 200 OK
ETag: "v3-d41d8cd"

GET /thing
If-None-Match: "v3-d41d8cd"

Last-Modified (weak validator)

An RFC-1123 timestamp. Sent back as If-Modified-Since. Cheaper to produce but only second-resolution.

HTTP/1.1 200 OK
Last-Modified: Tue, 12 May 2026 09:21:00 GMT

GET /thing
If-Modified-Since: Tue, 12 May 2026 09:21:00 GMT

5. Build a Cache-Control header

Pick directives and see the header (with a one-line plain-English summary) assemble live. The summary will warn you when directives contradict each other.

Storage

Forbid storage entirely.
Store, but always revalidate.
Forbid body rewrites.

Freshness

Revalidation

No serving stale on origin failure.
Same, but only for shared caches.
URL contents will never change.

Quick presets

Cache-Control:

6. Live simulator: watch the cache decide

Press Send request repeatedly. The browser-side cache will choose between a hit, a conditional GET, and a full miss based on the response's directives and how much time has passed since the last fetch. Time is sped up so you can see freshness expire.

Browser Browser cache empty Origin
Cache is empty. Click "Send request".

7. Practical recipes

Hashed JS/CSS bundle

Cache-Control:
  public,
  max-age=31536000,
  immutable

URL contains a content hash, so the body for that URL is fixed forever. Browser never needs to revalidate.

HTML entry document

Cache-Control:
  public,
  no-cache

Cacheable, but revalidate on every navigation. Pairs with ETag for cheap 304s.

News article behind a CDN

Cache-Control:
  public,
  max-age=60,
  s-maxage=600,
  stale-while-revalidate=86400

Edge holds it for 10 min, browser for 1 min. On expiry, edge serves the old copy and refetches in the background.

Per-user API response

Cache-Control:
  private,
  max-age=30

private stops the CDN from leaking one user's data to another. Vary: Authorization is also wise.

Bank statement / one-time token

Cache-Control:
  no-store

The most restrictive. Combine with Pragma: no-cache for HTTP/1.0 proxies if you're paranoid.

Static error page

Cache-Control:
  public,
  max-age=60,
  stale-if-error=86400

If the origin is on fire for a day, the edge can keep serving the last good error page.

Built as a learning aid. Authoritative source: RFC 9111: HTTP Caching.