Caching as a Contract Clause
Cache-Control is not a performance knob — it is a promise about staleness: who may store this response, for how long, and what "fresh enough" means. The most expensive header in HTTP is the one that let a shared cache store a private response.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
Freshness is a staleness budget, stated
A Cache-Control: max-age=300 header is a sentence in the contract: "any copy of this response may be served, by anyone allowed to hold it, without consulting us, for five minutes." That is a *data-freshness guarantee* — the same species of promise as read-after-write consistency (see Consistency as a Contract Clause) — dressed as a performance header. Whether five minutes of staleness is fine is a domain question the API team must answer per resource: product catalog, yes; account balance, no; feature flags, depends on what they gate.
The vocabulary is small and worth using precisely. max-age sets the fresh window. no-cache — misleadingly named — allows storing but demands revalidation before every use (pairs with ETags — see Conditional Requests: ETags, 304 and 412). no-store forbids retention entirely: the right clause for secrets and regulated data. stale-while-revalidate serves the stale copy while refreshing in the background — latency of a hit, freshness one request behind. And the defaults are treacherous: an API that sends *no* cache headers has not opted out of caching; it has delegated the decision to every intermediary's heuristics. Silence is not "no caching" — silence is "anyone's guess", and the fix costs one explicit header.
| Resource | Sensible clause | The promise being made |
|---|---|---|
| Product catalog page | public, max-age=300, stale-while-revalidate=60 | Anyone may serve this ≤5min stale; one request rides a stale copy during refresh |
| User's own profile | private, max-age=60 | Only this user's browser/app may cache; ≤60s staleness visible to its owner only |
| Account balance | private, no-cache + ETag | Store it, but ask us before every display; 304 when unchanged |
| Access token response | no-store | This must never rest on disk anywhere |
| Immutable build asset / versioned blob | public, max-age=31536000, immutable | The URL names the content; it can never be wrong (see Payload Size: 20KB, 200KB, 5MB) |
Who may cache: the public/private boundary
The public vs private directive decides *which caches* may hold the response — and it is a security boundary, not a tuning flag. private restricts storage to the end user's own cache (browser, app); public invites shared caches: CDN edges, corporate proxies. The classic catastrophe is one misplaced directive on an authenticated endpoint: GET /me marked public, max-age=60 (or left headerless behind an aggressive CDN default) means the first user's profile is cached at the edge and *served to the next sixty users who ask* — a data breach caused by a header, with no attacker involved (the CDN's position in the request path is covered in CDNs: The Networking View; the API's job is the contract it hands that machinery).
The safe posture for authenticated APIs: private (or no-store) as the default on everything, public as a deliberate, per-endpoint exception that must justify itself in review. Remember also the Vary header — a response that differs by Authorization, locale or API version must say so (Vary: Authorization, Accept-Language), or a shared cache will happily serve one variant to consumers of another. Getting Vary wrong is the subtler cousin of the public/private mistake: same mechanism, harder to spot, because the leak only occurs across variant boundaries.
Designing for cacheability — and living with invalidation
HTTP caching has a hard limit the contract must respect: there is no protocol-level invalidation. Once a response is in a browser cache with max-age=300, you cannot recall it — you can only wait out the window. (Your CDN offers purge APIs; the user's browser and the corporate proxy do not.) So max-age is a *commitment*: set it to the staleness the domain can survive on its worst day, not the average one. The escape hatches are structural: short windows plus revalidation (no-cache + ETag gives freshness at one conditional round trip per use), and URL versioning — put a content version in the path (/assets/app.3f9c.js, /catalogs/2026-08-25/…) so "invalidation" becomes publishing a new URL and the old one can be cached forever, immutable.
Cacheability is also a *shape* property, decided back in From Domain to Resources and API Granularity and the Chatty API: a response that mixes a public product listing with the caller's private wishlist flags is condemned to private (or worse, to leaking); split into two resources, the big one becomes public, max-age=300 at the CDN and the small personal one stays private. Same data, different composition, an order-of-magnitude difference in origin offload. Application-level caches — Redis behind your handlers — are a different discipline with real invalidation and their own failure modes (Cache Invalidation, Stampedes and Hot Keys and the architecture domain's caching material cover them); this lesson's subject is the contract you publish to caches you do *not* operate.
1GET /me2→ 200 OK3(no Cache-Control, no Vary)4 5# CDN default: cache 200s for 120s6# user A's profile served to users B..Z for 2 minutes7# meanwhile /catalog — also headerless — is NOT cached,8# and origin melts during the sale9# wrong things cached, right things not: the default double-fault1GET /me2→ 200 OK3Cache-Control: private, no-cache4ETag: W/"u42-v7"5Vary: Authorization6 7GET /catalog?page=18→ 200 OK9Cache-Control: public, max-age=300, stale-while-revalidate=6010ETag: W/"cat-20260825-1109"11 12# personal data never rests in shared caches;13# the catalog rides the CDN and origin sees ~1/300th of readsThe bad column is one missing header away from both failure modes at once: private data cached publicly, public data not cached at all. The good column costs four header lines and converts the CDN from a liability into the cheapest capacity you own.
Key points
- Cache-Control is a freshness contract: max-age states how stale a response may be served without asking — set it per resource from domain tolerance, not as a global tuning constant.
- public/private is a security boundary: default authenticated APIs to private/no-store and make public a reviewed, per-endpoint exception.
- Missing headers do not mean "no caching" — they mean every intermediary applies its own heuristics; explicitness costs one line.
- There is no protocol-level invalidation: escape via short windows + ETag revalidation, or URL versioning with immutable long-lived entries.
- Cacheability is a resource-shape decision: separating public from personal data in different resources is what lets the CDN carry the read load.
- Vary declares what the response depends on; omitting it leaks one consumer's variant to another through shared caches.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → API: ships no cache headers ("we'll tune performance later"); the CDN and proxies apply their defaults.
- 2CDN → users: caches an authenticated response under a shared key; user A's data renders for user B — a breach with no attacker.
- 3Team → panic: sets
no-storeglobally; origin load triples and every mobile screen slows by an RTT. - 4Product → team: catalog reads melt origin during a sale; the resource that *should* be public+max-age was never separated from the personal fields embedded in it.
- 5Team → retrofit: splitting resources and adding headers now changes response shapes and staleness behavior consumers already built against.
- Private responses in shared caches — the header-shaped data breach, discovered by a customer seeing someone else's account.
- Stale data outliving its domain tolerance (a price, a permission, a balance) with no recall mechanism until windows expire.
- Origin capacity sized for full read traffic because nothing was cacheable — the CDN you pay for serves 3% of requests.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Declare Cache-Control on every response explicitly; default authenticated endpoints to `private` (secrets to `no-store`) and require review to mark anything `public`.
- • Set max-age from the domain's worst-day staleness tolerance; pair short windows with ETag revalidation for cheap freshness (see [[conditional-requests]]).
- • Shape resources for cacheability: split public/shared data from per-user data so each carries its honest policy.
- • Version URLs for anything immutable, and set `Vary` for every dimension a response actually depends on.
- • Cache hit ratio per endpoint at the CDN: near-zero on hot public reads means missing/over-strict headers; the offload you are not getting is measurable money.
- • Alert on `Set-Cookie` or Authorization-dependent responses carrying `public` — the misconfiguration is grep-able before it is a breach.
- • Track origin traffic during cache purges and deploys: a stampede after every release means TTLs and revalidation are misconfigured for your deploy rhythm.
- • Shortening max-age or moving public→private takes one deploy plus one old-window of patience; loosening (private→public, longer TTLs) is instant but needs the security review, not just the config change.
- • Moving to URL-versioned immutable resources is additive: publish new-style URLs alongside, migrate consumers, let the old ones age out.
- • Consumers integrate against observed staleness: an endpoint that was effectively always-fresh becoming 5-minutes-stale is a behavior change worth announcing, even though no schema moved (see [[backward-compatibility]]).
- • Every second of max-age is a second of staleness you cannot recall from caches you do not operate — freshness windows are commitments, not hints.
- • Revalidation-heavy designs (no-cache + ETag) keep data fresh at one conditional RTT per use — cheaper than full fetches, pricier than silence.
- • Splitting resources for cacheability multiplies endpoints and forces clients into two calls where one kitchen-sink response used to (uncacheably) suffice.
Misconceptions
no-store); the only way to be cached correctly is to say how.no-store. Deploying the wrong one either kills your revalidation win or leaves secrets on disk.