• Lesson 7 of 11
  • 6 min read
  • Lesson

HTTP Caching & Conditional Requests (ETags)

HTTP caching for REST APIs: Cache-Control directives, ETags and Last-Modified, conditional GET with If-None-Match, 304 responses, and optimistic concurrency control.

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 freeStale? revalidate cheap304 no bodysame ETag locks writes
CACHING IS TWO MECHANISMS — DON'T CONFLATE THEMFRESHNESSCache-Control: max-age=Nreuse stored copy · ZERO networkthe common-case winVALIDATIONETag / If-None-Matchround-trip · NO body → 304the cheap fallbackDesign with both: aggressive freshness for the common case, validation as the cheap fallback when freshness lapses.
Freshness reuses a stored response with no call at all; validation cheaply confirms one with a bodyless round-trip.1

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

CACHE-CONTROL · THREE QUESTIONS IT ANSWERSHOW LONG FRESH?max-age=Nfresh for N secondss-maxage=Nshared caches only · winsExpires = legacy absolutetimestamp; max-agesupersedes it (no skew)WHO MAY STORE?publicany shared cache may storeprivatebrowser only · per-userno-storenever write to any cacheREUSE RULES?no-cachestore, but 304-check firstmust-revalidateonce stale, no serving stalestale-while-revalidateserve stale, refresh in bg
A still-fresh response is reused with zero contact with the origin — that's the whole win. 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

THE CONDITIONAL GET — REVALIDATE WITHOUT RE-DOWNLOADINGCache / clientholds ETag "v7"If-None-Match: "v7"Origincompares ETagsMATCH → 304Not Modified · NO bodyserve stored copy → save bandwidthCHANGED → 200full body + new ETagcache refreshes its copy
A 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

A STORED RESPONSE OVER ITS LIFETIMEFRESHwithin max-age · served freemax-ageelapsesSTALEexpired · must revalidateREVALIDATEconditional GET (ETag)304 → keepreset freshness200 → replacenew body+ETagstale-while-revalidate serves the stale copy instantly and revalidates in the background — hiding the round-trip from the user.
Stale is not dead: a stale entry is revalidated, not re-downloaded. A 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

IF-MATCH — REJECT THE WRITE THAT WOULD CLOBBERClientread ETag "v7"PUT · If-Match: "v7"Servercurrent ETag = ?still "v7" → 200 OKwrite applied · new ETag "v8"now "v8" → 412Precondition Failed · rejectedclient must re-fetch & reconcile
If the resource changed since the client read it, its ETag no longer matches and the server returns 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.

THE CACHE KEY — AND WHY VARY GUARDS ITDEFAULT KEY = URLone entry per /resource+ Vary: AcceptJSON entry ≠ XML entry+ Vary: Accept-Encodinggzip entry ≠ brotli entryTHE AUTH PITFALLshared cache + authed response→ user A's data served to user B 💥Fix: private · no-storeor Vary: Authorizationdefault authed responses to private,no-store
By default a cache keys on the URL. 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…
  • revalidated with the origin on each separate reuse
  • discarded whenever a shared proxy cache receives it
  • served straight from cache with no call to origin
  • returned only after the client sends If-None-Match
Q2. A valid conditional <code>GET</code> with <code>If-None-Match</code> returns…
  • a 200 carrying the full refreshed entity body again
  • a 304 status with no body, confirming it is fresh
  • a 412 status because the version tag did not match
  • a 404 status once the cached entry has gone stale
Q3. A <code>PUT</code> with <code>If-Match</code> on a since-changed resource yields…
  • 200 OK, applying the write over the newer revision
  • 304 Not Modified, skipping the write body entirely
  • 409 Conflict, merging both edits into one revision
  • 412 Precondition Failed, rejecting the stale write
Q4. Authenticated responses leak across users when a cache…
  • is shared yet omits private, no-store, or Vary auth
  • stores them privately inside each end user browser
  • marks the stored entries as no-store before reuse
  • adds Authorization to the Vary header per request

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.

Reveal model answer

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

Design-round framework — drive any caching/conditional-request prompt through these, out loud:
  1. Clarify scale & audience: read/write ratio, public vs per-user, tolerable staleness, latency & cost budget.
  2. Classify each resource — public-stable, public-volatile, per-user/authenticated — and pick a policy per class.
  3. Freshness layer: s-maxage at the edge vs max-age in the browser; stale-while-revalidate to hide refresh latency.
  4. Validation layer: emit ETag/Last-Modified so lapsed entries revalidate with a cheap 304, never a full re-transfer.
  5. Correctness & safety: set Vary precisely (never over-vary); default per-user payloads to private/no-store so a shared cache can't leak one user's data to another.
  6. Invalidation: choose TTL-expiry vs active purge/ban vs versioned URLs by how tight the staleness SLA is; reuse the ETag for If-Match optimistic concurrency on writes.
  7. 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.