59. Caching: the highest-leverage HTTP feature
59.1 The three caches in the path
browser cache -> (shared) CDN / proxy cache -> origin
private shared source of truth
Private caches hold one user's data; shared caches serve many users. The distinction drives every directive below.
59.2 Cache-Control directives that matter
Response directives:
| Directive | Meaning |
|---|---|
max-age=N |
Fresh for N seconds (relative to Date) |
s-maxage=N |
Overrides max-age for shared caches only |
public |
May be stored by shared caches even if normally not (e.g. with Authorization) |
private |
Browser only — CDNs must not store it |
no-cache |
May store, but must revalidate before every reuse. (Not "don't cache".) |
no-store |
Never write to disk/memory. The real "don't cache". |
must-revalidate |
Once stale, do not serve stale — revalidate or fail |
immutable |
Content will never change; don't revalidate even on reload |
stale-while-revalidate=N |
Serve stale for up to N s while refreshing in the background |
stale-if-error=N |
Serve stale for up to N s if the origin errors |
The single most common bug: no-cache does not mean "don't cache". If you
mean that, say no-store.
59.3 Validators and conditional requests
Origin response: Later request: Origin reply:
ETag: "abc123" If-None-Match: "abc123" -> 304 Not Modified (no body)
Last-Modified: <date> If-Modified-Since: <date> -> 304 Not Modified
ETagis an opaque version token. Strong ("abc") means byte-identical; weak (W/"abc") means semantically equivalent (fine for caching, not for range requests).- A
304carries headers but no body — that's the whole saving. If-Match/If-Unmodified-Sinceare the write direction: optimistic concurrency ("only update if it's still the version I read"), answering412 Precondition Failedon conflict.
59.4 Vary — the correctness trap
Vary tells caches which request headers change the response.
Vary: Accept-Encoding cache gzip and identity separately (almost always needed)
Vary: Accept-Language one entry per language
Vary: Cookie -> effectively uncacheable in a shared cache
Vary: User-Agent -> cache explosion; avoid
Get it wrong in either direction and you either serve the wrong variant to
someone, or destroy your hit rate. Vary: * means "never reuse".
59.5 Cache keys and CDN behaviour
A CDN's cache key defaults to roughly (method, scheme, host, path, query) plus
whatever Vary demands. Operationally you often want to normalise it:
- Strip tracking params (
utm_*,fbclid) from the key so they don't shard your cache. - Ignore unknown query params for static assets.
- Add a header to the key deliberately (e.g. device class) rather than
Vary: User-Agent.
Cache-busting pattern: serve immutable assets with a content hash in the
filename and Cache-Control: public, max-age=31536000, immutable; serve the HTML
that references them with no-cache. The HTML revalidates cheaply; assets never
do.
59.6 Invalidation
| Method | Notes |
|---|---|
| TTL expiry | Simplest; you wait |
| Purge / invalidate API | CDN-specific; may take seconds to propagate globally |
| Surrogate keys / cache tags | Tag responses (Surrogate-Key: product-42), purge by tag. The best pattern for dynamic sites. |
| Versioned URLs | No invalidation needed at all — the URL changes |
Surrogate-Control is a CDN-only sibling of Cache-Control (stripped before
reaching the browser), letting you cache aggressively at the edge and not at all
in the browser.
59.7 Real-world example
An e-commerce site had a 4% CDN hit rate. Cause: the origin sent
Set-Cookie on every response (a session cookie, even for anonymous static
pages), and the CDN — correctly — refuses to share a response carrying
Set-Cookie. Fix: only set the cookie on pages that need it, mark static assets
public, max-age=31536000, immutable with hashed filenames, and add
Surrogate-Key tags for product pages. Hit rate went to 94%; origin load fell
~20x.
Try it yourself
curl -sI https://example.com | grep -iE 'cache-control|etag|last-modified|age|vary'
# conditional request -- watch the 304
ET=$(curl -sI https://example.com | awk -F': ' '/[Ee]tag/{print $2}' | tr -d '\r')
curl -sI -H "If-None-Match: $ET" https://example.com | head -1
# see whether a CDN hit or missed (header name varies by vendor)
curl -sI https://www.cloudflare.com | grep -iE 'cf-cache-status|age|x-cache'