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.