Conditional Requests: ETags, 304 and 412
One mechanism, two superpowers: If-None-Match turns repeat reads into 200-byte 304s, and If-Match turns racing writes into honest 412s. The validator — the ETag — is a contract about when a representation counts as changed.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
The validator is the contract
A conditional request is a plain request plus a claim about state: "I have the version tagged W/"v14" — proceed only as appropriate." The server compares the claim against reality and either does the work or returns a tiny answer: 304 Not Modified for reads ("your copy is current"), 412 Precondition Failed for writes ("the resource moved; your claim is stale"). Both superpowers hang off the same primitive: the validator, usually an ETag — an opaque token the server mints per representation — or the cruder Last-Modified timestamp (one-second granularity, ambiguous under rapid writes; use it as a fallback, not the primary).
The design decision hiding inside "just add ETags" is *what the tag tracks*. A strong ETag promises byte-identity; a weak one (W/"…") promises semantic equivalence — the useful promise for JSON APIs, where key order or a bumped retrieved_at timestamp should not invalidate every client's cache. Deriving the tag from a version counter or updated_at that changes exactly when the domain data changes gives you the semantics cheaply; hashing the serialized response gives byte-precision at CPU cost and breaks the moment serialization becomes nondeterministic. Whatever the recipe, it is a *contract*: if the tag changes when nothing meaningful changed, revalidation is worthless churn; if it fails to change on a real edit, caches serve stale data and preconditions fail to fence — the worst failure in this lesson, and a silent one.
GET /projects/42/board HTTP/1.1 If-None-Match: W/"v14" Authorization: Bearer <token>
HTTP/1.1 304 Not Modified ETag: W/"v14" Cache-Control: private, max-age=0, must-revalidate # no body: ~200 bytes instead of the 180KB board. # A client polling every 10s downloads the board only # when it actually changed.
If-None-Match: bandwidth arithmetic
The read side is a straight bandwidth trade. A dashboard polling a 180KB resource every 10 seconds pulls ~1.5GB/day/client unconditionally; with If-None-Match, unchanged polls cost a ~200-byte 304 — a 900× reduction on every poll where nothing moved, which for most dashboards is nearly all of them. The server still pays to *know* the answer (fetch or track the current version), so 304s save transfer and client parse time more than server CPU — unless the validator check is cheap (a version column read) while rendering is expensive, in which case the server wins big too.
Two honesty notes. First, conditional reads complement, not replace, freshness caching: max-age (see Caching as a Contract Clause) eliminates the request entirely for its window; revalidation makes the *next* request cheap. The strongest pattern is both — cache for 30s, then revalidate. Second, the mechanism only pays where responses are re-requested unchanged: a feed that changes every poll gets zero 304s and pure overhead; a per-user config read on every app launch is the perfect customer. Measure the 304 rate before and after; it is the mechanism's ROI in one number.
- Best customers: large, slowly-changing, frequently re-read representations — configs, boards, catalogs, profile blobs.
- Poor customers: fast-churning feeds (no 304s), tiny responses (304 saves nothing), one-shot reads (nothing to revalidate).
- Client cost: store
ETag+ body per cached resource; sendIf-None-Match; handle 304-means-use-cached — SDKs should do this invisibly. - Combine with `Cache-Control`: fresh-window first, revalidate after — see Caching as a Contract Clause for who is allowed to cache what.
If-Match: the write fence
The write side reuses the validator as a concurrency fence. The lost-update race — two clients read v14, both edit, second write silently destroys the first (anatomy in The Lost Update, Step by Step) — is closed by making the claim mandatory: If-Match: W/"v14" on the write; the server compares and returns 412 Precondition Failed if the resource has moved. The loser refetches, sees the winner's change, and reconciles — merge, prompt the user, or retry the edit against the new version. Who reconciles, and how, is the real design decision; the status code just guarantees there *is* a loser instead of a silent casualty (strategy and alternatives in Optimistic Concurrency: Versions and If-Match).
Contract details that decide whether the fence holds: writes without If-Match on protected resources should be *rejected* (428 Precondition Required) rather than quietly accepted, or the fence only guards the clients that least need it. Every write response must return the fresh ETag, or clients are forced into an extra GET per edit cycle. And the 412 body should carry the current version — possibly the current representation — so reconciliation costs zero extra round trips. PUT and PATCH both take the fence identically (PUT vs PATCH covers what each method itself promises); the fence answers the question neither method can: "is my mental model of this resource still true?"
1A: GET /docs/7 → ETag W/"v14", body…2B: GET /docs/7 → ETag W/"v14", body…3A: PUT /docs/7 {…} → 200, now v154B: PUT /docs/7 {…} → 200, now v165# A's edit is gone. No error. No log line.6# Discovered by A, next week, as "the API lost my work".1A: GET /docs/7 → ETag W/"v14"2B: GET /docs/7 → ETag W/"v14"3A: PUT /docs/7 If-Match: W/"v14" → 200, ETag W/"v15"4B: PUT /docs/7 If-Match: W/"v14" → 412 Precondition Failed5 { "error": { "code": "version_conflict",6 "current_etag": "W/\"v15\"" } }7B: GET /docs/7 → merge or prompt → PUT If-Match: W/"v15"The unfenced flow has no error to observe — data loss masquerades as success, which is why it survives until a customer notices. The fence converts the race into a visible, handleable 412, and the returned current_etag makes recovery a refetch instead of an investigation.
Key points
- One validator powers both wins: If-None-Match makes repeat reads cost ~200 bytes; If-Match makes concurrent writes fail loudly instead of losing data silently.
- The ETag is a contract about change: it must move exactly when the representation meaningfully changes — over-churning wastes the mechanism, under-changing silently breaks caches and fences.
- Weak ETags (semantic equivalence, e.g. from a version counter) fit JSON APIs better than byte-hashes of nondeterministic serialization.
- 304s pay on large, slow-changing, re-read resources; measure the 304 rate to know the ROI. Combine with max-age freshness.
- A fence that is optional protects no one: require If-Match (428 without it) on resources where lost updates are expensive, and return the fresh ETag on every write.
- 412 handling is a client obligation: refetch, reconcile, retry — the contract should hand back the current version to make that cheap.
Progressive depth
Overview
A conditional request is a request with a precondition: "only do this if the resource is still the version I think it is" (If-Match) or "only send it if it changed" (If-None-Match). The server checks the precondition against the current validator and either performs the request, answers 304 Not Modified, or refuses with 412 Precondition Failed.
Practical
Return an ETag on every GET of a mutable resource. Clients cache the body and revalidate with If-None-Match; unchanged resources cost a tiny 304 instead of a full payload. For writes, require If-Match on PUT/PATCH/DELETE of anything two actors can touch, answer 412 with the current representation, and document that a missing If-Match is either rejected (428 Precondition Required) or means "last writer wins" — never leave it unstated.
Advanced
Validators must change whenever the *representation* changes, not just the row — a new field added by a deploy, a different serialization, or a per-user projection all need a different ETag. Weak validators (W/"…") allow semantically-equal-but-byte-different bodies and cannot be used for range requests or writes; strong ones must be byte-exact per representation, which is why compression at a proxy that rewrites bodies without touching the ETag breaks If-None-Match silently. Pair this with Optimistic Concurrency: Versions and If-Match for the write side and Caching as a Contract Clause for the read side.
Internals
Cheap ETags are derived, not hashed: a version counter or updated-at plus a representation version suffices and costs nothing on the hot path; hashing the serialized body costs a full render before you can answer 304. The 412 check should be atomic with the write — a compare-and-set on the version column in the same statement — otherwise two If-Match: "v7" writers can both pass the check and one update is lost anyway; see The Lost Update, Step by Step for the exact interleaving.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → API: skips validators; polling clients re-download full payloads and editors overwrite each other, but staging never notices.
- 2Mobile team → API: polls a 180KB resource on the app's home screen; bandwidth bills and battery complaints climb.
- 3Team → API: adds ETags by hashing the JSON body; a nondeterministic map ordering makes every tag unique, so the 304 rate is 0% and everyone concludes "ETags don't work".
- 4Team → writes: implements If-Match as optional; the web app sends it, the older mobile app does not and keeps winning races it should lose.
- 5Users → support: "the app lost my edits" tickets accumulate with no correlated errors — the signature of unfenced writes.
- Bandwidth and battery burned re-downloading unchanged data — the tax lands on the slowest networks first.
- Silent lost updates between concurrent editors: user work destroyed with 200s in every log.
- A broken validator (never-changing ETag) is worse than none: caches confidently serve stale data and fences confidently pass stale writes.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Mint validators from a per-resource version counter bumped on every meaningful write — deterministic, cheap, and honest about semantic change.
- • Return ETag on every read *and* every write response; support If-None-Match on reads, If-Match on writes, uniformly across the API.
- • Require preconditions (428) on resources where lost updates destroy human work or money state; keep them optional where overwrites are harmless.
- • Put the current version (and ideally the representation) in 412 bodies so reconciliation needs no extra round trip.
- • Track the 304 rate per endpoint: it is the read-side ROI, and a sudden drop means the validator started over-churning.
- • Track 412 rates per resource and client: zero on multi-editor resources means the fence is not being used; spikes mean genuine contention worth product attention.
- • Alert on identical ETags across representations that differ (sampled comparison) — the silent under-change failure that breaks everything downstream.
- • Adding validator support is purely additive: clients that ignore ETags keep full-response behavior.
- • Changing the ETag recipe invalidates every cached tag once — a one-time revalidation stampede to schedule, not a breaking change.
- • Tightening optional If-Match to required is breaking for non-sending clients: telemetry on precondition-less writes first, then a deprecation window (see [[backward-compatibility]]).
- • Server-side cost: version tracking on every resource and a validator check on every conditional request — cheap with a version column, expensive if computing the tag means rendering the response anyway.
- • Client complexity: storing tags, handling 304 and 412 paths — invisible when the SDK does it, a support burden when raw-HTTP integrators must.
- • Required preconditions add a failure mode (428/412) that simple scripts must now handle; the protection taxes every writer to save the concurrent ones.