🥛 Analogy
HTTP caching is a fridge with use-by dates. The producer stamps each carton (max-age) and says whether it is yours alone (private) or shared (public). You can demand a fresh carton with plenty of life left (min-fresh), insist on a brand-new one before cooking something important (no-cache), or accept slightly old milk rather than go thirsty when the shop is shut (max-stale, stale-if-error). An ETag is the batch number telling you whether what is on the shelf is the exact carton you last checked.
Caching is built into REST
Cacheability is one of Fielding’s core REST constraints: the producer can mark responses with caching hints so consumers and intermediaries avoid needless round trips. Remote data sources are the usual cause of perceived slowness; HTTP caching is the cheapest fix. Caching is, in effect, “low-fidelity replication” — a local copy that is good enough for reads.
Providers set directives tuned to volatility
The provider marks each response with scope and lifetime, sized to how fast the data changes:
- Scope —
public(shared caches may store it) orprivate(per-user). - Lifetime —
max-agein seconds. Static reference data → hours or days; a shopping cart → seconds. - Handling —
no-store(never cache),no-cache(revalidate before use),must-revalidate(do not serve stale), andimmutable(RFC 8246) for long-lived assets. - Validation — an
ETagplusmust-revalidatefor data that changes.
Cache-Control: public, max-age=600
ETag: "a1b2c3"
Cache-Control: public, max-age=300, must-revalidate, stale-if-error
Consumers qualify freshness
The consumer can tighten or relax those rules:
max-age/min-fresh— demand a response with enough remaining life.no-cache— force a fresh fetch (e.g. immediately before an edit).max-stale/stale-if-error— accept stale data for read-only use, or when the source errors.
A read-only aggregator might fetch the editable customer record with no-cache but the orders list with max-stale — fresh where it matters, fast where it does not.
sequenceDiagram
participant C as Consumer
participant Cache as Shared Cache
participant S as Service
C->>Cache: GET /user/42
alt fresh copy within max-age
Cache-->>C: 200 OK (cached) + ETag
else stale or absent
Cache->>S: GET /user/42 (If-None-Match: "a1b2c3")
alt unchanged
S-->>Cache: 304 Not Modified
Cache-->>C: 200 OK (revalidated)
else changed
S-->>Cache: 200 OK + new ETag
Cache-->>C: 200 OK + new body
end
endETags also power conditional writes
The same ETag that drives a conditional GET (If-None-Match → 304 Not Modified) drives safe updates. On a write, If-Match: "<etag>" performs optimistic concurrency: if the stored representation has changed, the server returns 412 Precondition Failed, and the client should GET, merge, and retry. ETags are per-representation — an HTML ETag is not the JSON ETag — and If-None-Match: * enables idempotent PUT-create.
graph TD
W["PUT with If-Match ETag"] --> Q{"Stored ETag still matches?"}
Q -->|"yes"| OK["200 OK<br/>new ETag returned"]
Q -->|"no, changed"| Fail["412 Precondition Failed"]
Fail --> Retry["GET, merge, retry"]
Retry --> WUse Vary to scope cache reuse
The Vary header declares which request elements must match for a cached entry to be reused — for example Vary: Authorization ensures one user’s cached response is never served to another. Get this wrong and you leak data across consumers.
Caching can hide broken deploys
A proxy’s cached good response can mask broken software: a canary looks healthy because the proxy is replaying a stale success, you roll out fully, and then the proxy bounces and 500s appear everywhere. During risky changes, set Cache-Control: no-cache, no-store so you see the real behaviour.
Keep no-cache sparing
no-cache is a cache-buster — overusing it throws away the very benefit you are trying to get. Reserve it for genuinely volatile or edit-critical reads, and let everything else cache to a lifetime that matches its change rate.
See also
- Idempotency and safety — ETags and If-Match for safe writes.
- Pagination and filtering — cacheable, replayable query resources.
- REST and the Richardson Maturity Model — cacheability as a REST constraint.
When to use it — and when not
✅ Reach for it when
- A response is read far more often than it changes and perceived latency matters.
- You need optimistic concurrency control on updates via ETags.
- You want consumers to tolerate staleness gracefully when a source is unavailable.
⛔ Think twice when
- A deploy where a stale cached result could mask broken software (use no-cache, no-store).
- Highly volatile data where caching would serve wrong answers more than right ones.
Related topics
The network is unreliable, so design writes to be safely repeatable — prefer idempotent PUT with conditional headers, and make the payload itself idempotent too.
api-restPagination and FilteringDesign collections to evolve: wrap arrays in an object, paginate with an opaque next link, filter with a standard expression language, and never smuggle SQL in a query parameter.
api-restREST and the Richardson Maturity ModelREST's constraints and the four levels of the Richardson Maturity Model — and why Level 2 is the practical sweet spot for most HTTP APIs.
Check your understanding
Score: 0 / 41. Who sets Cache-Control directives and tunes them to data volatility?
Providers mark scope and lifetime; static data gets hours/days, a cart gets seconds, with ETag + must-revalidate for changing data.
2. What is an ETag used for?
An ETag identifies a specific representation; If-Match enables optimistic concurrency, If-None-Match enables conditional GET and PUT-create.
3. During a deploy, how do you stop a proxy's cached result masking broken software?
A canary can look fine because a proxy serves a cached good response, then the full rollout breaks; no-cache, no-store prevents that masking.
4. What does the Vary header control?
Vary: Authorization, for example, ensures a cached response is only reused for requests with the same authorization context.
Comments
Sign in with GitHub to join the discussion.