# HTTP Caching: The Distributed System You Already Deployed

> Between your server and each user sit browser caches, CDN tiers, and shared proxies you don't operate — all obeying Cache-Control headers you may have set by accident. The freshness/validation model, and the headers that cause incidents.

URL: https://harshith.in/blog/http-caching-contract/
Author: Harshith Sunku (Staff Engineer) — https://harshith.in/about/
Tags: web, distributed-systems, performance

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.
