Every HTTP response you emit is an instruction to a distributed cache hierarchy you mostly don’t control: the browser’s memory and disk caches, corporate and mobile-carrier proxies, and however many CDN tiers sit in front of you. Get the headers right and origin traffic drops 95% with instant pages; get them subtly wrong and you’re debugging why one user sees another’s account page (real incident category, revisited regularly). The model is small — freshness, validation, and keys — but it rewards being learned exactly.
Freshness: Permission to Not Ask
Cache-Control: max-age=3600 grants any cache the right to serve the
response for an hour without contacting you. That’s the contract’s
sharp edge: there is no take-back — no header you emit later reaches
caches that already stored the old one (CDNs offer purge APIs;
browsers offer nothing). So max-age is a promise about your own
deploy discipline, and the industry’s mature answer is the
immutable pattern: content-hashed filenames
(app.3f9c1d.js) served with
Cache-Control: public, max-age=31536000, immutable — cache forever,
“invalidate” by shipping HTML that references a new name. The HTML
itself gets the opposite treatment (no-cache or short max-age +
revalidation), making the document the single freshness root and
everything beneath it immutable. This one pattern is most of frontend
caching done right.
The directive zoo, disambiguated because the names actively mislead:
no-cache means store but revalidate every use (fine, cheap);
no-store means never write to disk (the one for sensitive data);
private restricts storage to the browser (mandatory for
personalized responses — a public on a Set-Cookie’d page is how
shared caches serve someone else’s session);
s-maxage overrides max-age for shared caches only — the standard
“CDN caches for 10 minutes, browsers always revalidate” split.
stale-while-revalidate=60 is the modern workhorse: serve stale
instantly, refresh in the background — latency of a hit, freshness
one request behind. And its sibling stale-if-error keeps your site
up through origin outages, which is cheap resilience nobody
configures until after the outage.
Validation: Asking Cheaply
Expired doesn’t mean refetch — it means revalidate: the cache sends
If-None-Match: "<etag>" (or If-Modified-Since), and the origin
answers 304 Not Modified, headers only, no body. For a 2 MB API
response that’s a 500-byte round trip; freshness renewed. The
implementation details that bite: ETags must actually be stable
(default file ETags derived from inode metadata differ across servers
behind a load balancer — cache-hit lottery; hash content or config
them consistently), weak vs strong matters for ranges, and your
framework may be generating ETags by hashing the response it just
rendered — bandwidth saved, CPU not.
Keys: What “Same Request” Means
A cache entry’s key defaults to the URL — and every mismatch between
key and actual variance is either a cache-miss epidemic or a
data-leak. Vary: Accept-Encoding splits entries by encoding
(table stakes); Vary: Origin for CORS’d APIs; forgetting Vary on
content-negotiated or personalized responses serves the wrong
variant to the wrong client. The inverse failure: keying on things
that don’t matter — marketing query params (utm_*) exploding one
page into a thousand cache entries — is why CDNs offer cache-key
normalization, and why hit-rate dashboards, not intuition, should
audit key design. (Related and underrated: request collapsing —
good CDNs coalesce concurrent misses for one key into a single
origin fetch, your free defense against the thundering-herd-on-
expiry problem; know whether yours does it.)
Debugging is header archaeology, and it’s pleasantly mechanical:
curl -sI the resource, read Cache-Control/ETag/Age (Age > 0
proves a shared cache served you), then the CDN’s verdict header
(x-cache, cf-cache-status: HIT/MISS/EXPIRED/DYNAMIC each name a
different fix). Most “caching is broken” reports decompose in five
minutes of this into one of: wrong directive, missing Vary,
unstable ETag, or a cache key that doesn’t match reality.
Takeaways
- max-age is irrevocable permission; pair immutable hashed assets (cache forever) with a revalidated HTML root — invalidation by renaming.
- no-cache revalidates, no-store forbids storage, private forbids shared caches — personalized responses need private (or no-store) or you will eventually leak sessions.
- 304 revalidation makes expiry cheap; make ETags stable across your fleet or lose the benefit silently.
- The cache key is the correctness boundary: Vary what varies, normalize what doesn’t, audit with hit-rate data.
- stale-while-revalidate and stale-if-error buy hit-latency freshness and outage resilience — the two cheapest wins in the spec.