Caching & conditional requests
Published
Why this, first. Caching and ETags are where REST’s cacheable
constraint stops being theory and starts paying for itself in latency and load. The senior
twist: the same ETag that powers a cache validator also powers
conditional writes (If-Match) — the HTTP-native way to prevent
lost updates. It’s a favorite interview follow-up, and most candidates know only the caching
half. This lesson makes both halves something you can draw.
Fresh? serve it free
Stale? revalidate cheap
304 no body
same ETag locks writes
Freshness — the Cache-Control directives map
The origin declares a response’s lifetime; while it’s fresh, caches serve it directly. The directives you must be fluent in: 1
no-cache is the confusingly named one: it does cache; it just won't serve without a 304 check.
✗ Myth: “no-cache means don’t cache it.”
✓ Truth: no-cache stores it but revalidates before every reuse;
no-store is the one that never writes to any cache.
Validation — the If-None-Match → 304 round-trip
When freshness lapses, a cache validates rather than re-downloads. The client echoes
the stored ETag in If-None-Match; if it still matches, the server
replies
304 Not Modified with no body.
2
304 revalidates freshness for the price of headers — no entity body crosses the
wire. That's the bandwidth saver behind every well-tuned cache.
ETag + If-None-Match An opaque version tag for the representation.
3
Echoed in If-None-Match; still matching → 304 with no body.
Last-Modified + If-Modified-Since Timestamp-based, weaker (1-second granularity, clock issues). The fallback when you can’t compute an ETag cheaply.
Strong validator — “abc” Byte-for-byte identical. Required for Range
requests and what you want for concurrency control.
Weak validator — W/”abc” Semantically equivalent. Fine for cache revalidation of cosmetically-varying responses.
The fresh → stale → revalidate lifecycle
304 resets
its freshness window; a 200 replaces it.
An ETag is both a cache validator and a concurrency token — same header, two superpowers.
Read it, and you confirm freshness with a 304. Send it back on a write, and you
enforce optimistic locking. One value, two roles.
Conditional writes — optimistic concurrency
This is the senior highlight and the part most candidates miss. The
lost-update problem: two clients GET the same resource, both edit,
both PUT — the second silently overwrites the first. The HTTP-native fix is
optimistic concurrency control using the same ETag.
2
412 Precondition Failed. If-None-Match: * does the dual job
for creation — fail if it already exists, giving a race-free create-only.
The happy path Client sends If-Match: “etag” on PUT/
PATCH; resource unchanged → write applies, costs nothing extra.
The conflict Resource changed → 412 Precondition Failed. The client
re-fetches and reconciles rather than clobbers.
Why not pessimistic locking Holding a lock across stateless requests violates statelessness, ties up resources, and breaks when a client vanishes mid-edit.
Why optimistic wins Assumes conflicts are rare, detects them at write time, costs nothing on the happy path — a far better fit for HTTP.
Caching layers & the Vary trap
Caches live in two places, mapping straight onto REST’s cacheable and layered system constraints: private caches (the user’s browser) and shared caches (CDN, reverse proxy, API gateway). A shared cache is one node many users hit — which is exactly why the cache key matters.
Vary adds request headers to that key. Get it
wrong and the cache serves the wrong representation — or worse, the wrong user's data.
♻️ Memory rule: Freshness serves free, validation revalidates cheap (
304, no body); the same ETag that proves freshness also enforces
optimistic locking via
If-Match → 412; on shared caches, mark authed responses
private, no-store.
Retrieval practice
Close the lesson in your mind first, then answer from memory. Effortful recall is what converts this from “I read it” to “I own it.” Immediate feedback below.
Q1. A still-fresh response under <code>Cache-Control: max-age</code> is…
Q2. A valid conditional <code>GET</code> with <code>If-None-Match</code> returns…
Q3. A <code>PUT</code> with <code>If-Match</code> on a since-changed resource yields…
Q4. Authenticated responses leak across users when a cache…
Rehearse the answer out loud
Interviewer: "Two users open the same document and both hit Save. How does your API stop the second save from silently clobbering the first?" ~60 seconds, from memory (no scrolling up) — then reveal the model answer and compare.
“That’s the classic lost-update problem, and I solve it with
optimistic concurrency via ETags. The GET returns the document
together with a strong ETag — a version tag for that exact revision. Any update
must send it back as If-Match: <etag>. If the document changed since the
client read it, the ETag no longer matches and the server returns
412 Precondition Failed. So the second save is rejected, and that client is
forced to re-fetch and merge rather than blindly overwrite.
I’d reach for this over pessimistic locking, which is a poor fit for a web
API — it would hold a lock across stateless requests and strand resources if a client walks
away mid-edit. Optimistic control costs nothing on the happy path and only detects conflicts
at write time. And it’s the same ETag that drives 304 conditional
GETs for caching — one value, two jobs.”
Why this scores: it names the lost-update problem, solves it with HTTP-native optimistic concurrency, contrasts it against pessimistic locking with a reason, and connects validation back to caching — the senior signal.
I’m your teacher — ask me anything. Want to walk through what a 412 round-trip
looks like on the wire? Curious how stale-while-revalidate interacts with a CDN?
Want me to grade your rehearsal answer, or fire harder follow-ups on weak vs. strong ETags? Just
say so in the chat.
Primary source (read this next)
📖
“HTTP caching” — MDN: ~15 min, the clearest high-trust treatment of freshness vs. validation and the
Cache-Control directives you just learned. For the normative detail on conditional
requests, RFC 9110 is the primary
source.
Interview — pick your bar
Answer out loud in ~60s, then reveal. Core = recall · Senior = trade-offs & failure modes · Staff = synthesis under ambiguity · System Design = open design round (a different axis, not a harder level).
What are the two distinct halves of HTTP caching?
Freshness lets a cache reuse a stored response with no network call at all. Validation lets a cache cheaply confirm a stored response is still good via a round-trip that carries no body. Conflating the two is the tell of a shallow answer — you design with both: aggressive freshness for the common case, validation as the cheap fallback when freshness lapses.
What's the difference between public and private?
public may be stored by any cache, including shared ones (CDN/proxy). private restricts storage to the end user's own browser — never a shared cache. private is the guard for per-user payloads.
Walk through a conditional GET with If-None-Match. What status comes back if nothing changed?
The client sends the stored ETag in If-None-Match. If the representation still matches, the server returns 304 Not Modified with no body — the client reuses its cached copy. Only if it changed does the server return 200 with the full body.
What status code signals a failed precondition, and what does the client do?
412 Precondition Failed. The client should re-GET the resource (and its new ETag), merge or re-apply its change against the current state, and retry the write with the updated If-Match.
no-store vs no-cache — what's the difference? (Most candidates trip here.)
no-store means never write it to any cache — the directive for genuinely sensitive data. no-cache is confusingly named: it does store the response, but must revalidate with the origin before every reuse (expecting a 304). So no-cache still saves bandwidth on unchanged bodies; no-store saves nothing because nothing is kept.
How does If-Match prevent lost updates, end to end?
The client GETs the resource and its ETag, then sends If-Match: "<etag>" on the PUT/PATCH. The server applies the write only if the current ETag still matches. If the resource changed underneath, the ETag no longer matches and the server returns 412 Precondition Failed — the write is rejected and the client must re-fetch and reconcile rather than clobber. This is optimistic concurrency control.
Strong vs weak ETags ("abc" vs W/"abc") — what's the distinction and when do you use each?
A strong validator means byte-for-byte identical. A weak one (W/ prefix) means semantically equivalent — the payload may differ cosmetically (whitespace, a regenerated timestamp) but is "the same" for the user. Strong is required for Range requests and is what you want for concurrency control; weak is fine for cache revalidation of responses that vary only cosmetically.
What is the cache key by default, and what does Vary change?
By default a cache keys on the request URL (plus method). Vary adds named request headers to that key so different header values get different cache entries — e.g. Vary: Accept partitions JSON from XML, Vary: Accept-Encoding separates gzip from brotli. Get it wrong and the cache serves the wrong representation — or worse, the wrong user's data.
How would you generate ETags, and what's the cost trade-off?
Two families: content hashes (hash the serialized body) — exact but you pay CPU to render and hash on every request, even ones that end in a 304; or version metadata (a row's updated_at or a monotonic version column) — cheap to read, strong if the version is reliable, and computable without rendering the full body. At scale, prefer a stored version/sequence number so you can answer a conditional request without materializing the representation. Watch out for hashing that differs across nodes (key ordering, serializer version) — it'll break revalidation and tank your hit rate.
Same ETag drives caching and concurrency — articulate why that's elegant, and any tension.
One value, two jobs: read it back as If-None-Match and you get a 304 cache validation; send it back as If-Match on a write and you get optimistic locking. Elegant because the server maintains a single version notion. The tension: concurrency wants a strong, exact validator (any change must fail the write), while cache revalidation tolerates a weak one — so if you serve weak ETags for cosmetic-variance caching, don't reuse them for If-Match, or you'll accept writes against a "semantically equal" but actually-different revision.
A write happens at the origin but a CDN still serves the old copy. What's going on and how do you fix it?
The edge entry is still within its s-maxage freshness window, so the CDN serves it without asking the origin — by design. Fixes: shorten s-maxage and lean on revalidation; issue an explicit purge/ban to the CDN on write (eventual, per-POP propagation, so allow a moment); or use stale-while-revalidate so the next request triggers a background refresh. Pick based on how tight your staleness SLA is — instant correctness means active purge plus a short TTL as a safety net.
- Clarify scale & audience: read/write ratio, public vs per-user, tolerable staleness, latency & cost budget.
- Classify each resource — public-stable, public-volatile, per-user/authenticated — and pick a policy per class.
- Freshness layer:
s-maxageat the edge vsmax-agein the browser;stale-while-revalidateto hide refresh latency. - Validation layer: emit
ETag/Last-Modifiedso lapsed entries revalidate with a cheap304, never a full re-transfer. - Correctness & safety: set
Varyprecisely (never over-vary); default per-user payloads toprivate/no-storeso a shared cache can't leak one user's data to another. - Invalidation: choose TTL-expiry vs active purge/ban vs versioned URLs by how tight the staleness SLA is; reuse the
ETagforIf-Matchoptimistic concurrency on writes. - Observability & failure modes: measure hit rate / origin offload; name the risks — stale-after-write, wrong-representation from a missing
Vary, cross-user leak, thundering-herd revalidation.
Design the caching & conditional-request strategy for a read-heavy public product catalog API fronted by a CDN.
A strong answer covers: classify resources first — catalog/product pages are public-stable, price/inventory are public-volatile, the cart/account is per-user → for public-stable, set public with a generous s-maxage at the CDN and a shorter max-age in the browser so you can purge the edge while clients return soon, and emit strong ETags so lapsed entries revalidate with a cheap 304 rather than re-shipping the body → add stale-while-revalidate so users never wait on origin during a refresh → for volatile price/inventory, short TTL or validation-only plus an active purge on write where instant correctness matters → per-user data: private, no-store, kept off the shared cache → set Vary: Accept, Accept-Encoding precisely and never over-vary (high-cardinality headers shred hit rate) → name the trade-off: long edge TTL maximizes offload but risks stale-after-write unless you can purge; ETag validation never serves wrong data but costs a round-trip → measure cache hit rate and origin offload as the success metric, and guard against a thundering herd on simultaneous expiry with jittered TTLs or stale-while-revalidate.
Design optimistic-concurrency control for a collaborative document API where many clients edit the same resources.
A strong answer covers: the hazard is the lost update — concurrent read-modify-write over a stateless protocol → on GET, return the document with a strong, exact ETag for that revision (a stored version/sequence column, not a body hash, so you can answer without materializing the representation) → require every PUT/PATCH to carry If-Match: <etag>; if the current version no longer matches, reject with 412 Precondition Failed so the client must re-fetch and reconcile rather than clobber → enforce the check atomically at the store — UPDATE … WHERE id=? AND version=? and check rows-affected, or a transaction — because the HTTP layer only communicates the precondition, it doesn't enforce it → offer race-free create via If-None-Match: * → reuse the same version as an If-None-Match validator for 304 caching (one value, two jobs), but do not reuse a weak ETag for If-Match or you'll accept a write against a merely "semantically equal" revision → name the trade-off: optimistic is lock-free and cheap on the happy path but thrashes under frequent conflicts, where a short-lived scoped lock with a lease/TTL is the fallback → surface conflicts to the UI for merge, and log 412 rates as a signal that contention is too high.
📄 Keep handy:
Caching, pagination & versioning reference
◂ Prev ·
Next ▸ Lesson 8These notes reflect my current understanding and are updated as I learn, build, and discover better explanations.