Lesson 7 · Caching · visual edition

Caching & conditional requests

~7 min · one diagram per idea · retrieval practice at the end

Published

HTTP cachingConditional requestsStatus codesHTTP methodsREST

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

CACHING IS TWO MECHANISMS — DON'T CONFLATE THEM FRESHNESS Cache-Control: max-age=N reuse stored copy · ZERO network the common-case win VALIDATION ETag / If-None-Match round-trip · NO body → 304 the cheap fallback Design 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 ANSWERS HOW LONG FRESH? max-age=N fresh for N seconds s-maxage=N shared caches only · wins Expires = legacy absolute timestamp; max-age supersedes it (no skew) WHO MAY STORE? public any shared cache may store private browser only · per-user no-store never write to any cache REUSE RULES? no-cache store, but 304-check first must-revalidate once stale, no serving stale stale-while-revalidate serve 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-Match304 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-DOWNLOADING Cache / client holds ETag "v7" If-None-Match: "v7" Origin compares ETags MATCH → 304 Not Modified · NO body serve stored copy → save bandwidth CHANGED → 200 full body + new ETag cache 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 LIFETIME FRESH within max-age · served free max-age elapses STALE expired · must revalidate REVALIDATE conditional GET (ETag) 304 → keep reset freshness 200 → replace new body+ETag stale-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 CLOBBER Client read ETag "v7" PUT · If-Match: "v7" Server current ETag = ? still "v7" → 200 OK write applied · new ETag "v8" now "v8" → 412 Precondition Failed · rejected client 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 IT DEFAULT KEY = URL one entry per /resource + Vary: Accept JSON entry ≠ XML entry + Vary: Accept-Encoding gzip entry ≠ brotli entry THE AUTH PITFALL shared cache + authed response → user A's data served to user B 💥 Fix: private · no-store or Vary: Authorization default 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-Match412; 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.

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 storeUPDATE … 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.

Was this lesson helpful? Thanks — noted.

These notes reflect my current understanding and are updated as I learn, build, and discover better explanations.