HTTPcachingcache-controlfreshnesscdnstaleness

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.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
For each response: who is allowed to cache it, for how long may they serve it without asking, and what staleness has the consumer actually agreed to?
Consumers
A chain of caches between handler and user — browser cache, mobile SDK cache, corporate proxy, CDN edge — plus the clients whose correctness silently depends on how stale each layer is allowed to be.
The promise
Every response declares its cacheability explicitly: public or private, fresh for a stated window, revalidated after — so performance comes from design rather than from intermediaries guessing, and private data never sits in a shared cache.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

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.

The staleness budget, per resource type
ResourceSensible clauseThe promise being made
Product catalog pagepublic, max-age=300, stale-while-revalidate=60Anyone may serve this ≤5min stale; one request rides a stale copy during refresh
User's own profileprivate, max-age=60Only this user's browser/app may cache; ≤60s staleness visible to its owner only
Account balanceprivate, no-cache + ETagStore it, but ask us before every display; 304 when unchanged
Access token responseno-storeThis must never rest on disk anywhere
Immutable build asset / versioned blobpublic, max-age=31536000, immutableThe 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.

private + public hitspublic onlymisses + revalidationsUserBrowser cache (private)Corporate proxy (shared)CDN edge (shared)API origin
UserLLMAgentToolDataDecisionHumanGuardrail

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.

Headerless API behind a CDN with defaults
1GET /me
2200 OK
3(no Cache-Control, no Vary)
4
5# CDN default: cache 200s for 120s
6# user A's profile served to users B..Z for 2 minutes
7# meanwhile /catalog — also headerless — is NOT cached,
8# and origin melts during the sale
9# wrong things cached, right things not: the default double-fault
Cacheability declared per resource
1GET /me
2200 OK
3Cache-Control: private, no-cache
4ETag: W/"u42-v7"
5Vary: Authorization
6
7GET /catalog?page=1
8200 OK
9Cache-Control: public, max-age=300, stale-while-revalidate=60
10ETag: 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 reads

The 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.

  1. 1
    Team → API: ships no cache headers ("we'll tune performance later"); the CDN and proxies apply their defaults.
  2. 2
    CDN → users: caches an authenticated response under a shared key; user A's data renders for user B — a breach with no attacker.
  3. 3
    Team → panic: sets no-store globally; origin load triples and every mobile screen slows by an RTT.
  4. 4
    Product → 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.
  5. 5
    Team → retrofit: splitting resources and adding headers now changes response shapes and staleness behavior consumers already built against.
What breaks
  • 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.

Design the contract
  • • 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.
Observe in production
  • • 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.
Evolve without breaking
  • • 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]]).
What it costs
  • • 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

Claim
“We do not cache anything — we sent no cache headers.”
Reality
No headers means no *instructions*. Browsers apply heuristic caching, CDNs apply configured defaults, corporate proxies do as they please. The only way to not be cached is to say so (no-store); the only way to be cached correctly is to say how.
Claim
“Caching is the infrastructure team's concern, not the API contract's.”
Reality
Only the API team knows whether a 5-minute-stale balance is acceptable and which responses contain private data. Infrastructure can operate the caches; what they may hold and for how long is a per-resource domain promise — contract, not configuration.
Claim
“no-cache means the response will not be cached.”
Reality
It means "stored but revalidated before each use" — the foundation of the efficient ETag/304 pattern. The directive that forbids storage is no-store. Deploying the wrong one either kills your revalidation win or leaves secrets on disk.