HTTPetagif-match304412validatorscaching

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.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
Can clients ask "has this changed?" instead of re-downloading, and say "apply this only if unchanged" instead of overwriting — and what exactly does the ETag promise?
Consumers
Polling clients re-fetching resources on a timer, mobile apps syncing on flaky metered radios, caches revalidating stale entries, and any client doing read-modify-write on a resource that other writers can touch.
The promise
Every mutable resource carries a validator that changes exactly when the representation meaningfully changes — so reads can revalidate for pennies (304) and writes can be fenced against lost updates (412).
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

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.

Revalidation: the second read costs headers, not payload
Request
GET /projects/42/board HTTP/1.1
If-None-Match: W/"v14"
Authorization: Bearer <token>
Response
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; send If-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?"

Unfenced read-modify-write: last writer silently wins
1A: GET /docs/7ETag W/"v14", body
2B: GET /docs/7ETag W/"v14", body
3A: PUT /docs/7 {…} → 200, now v15
4B: PUT /docs/7 {…} → 200, now v16
5# A's edit is gone. No error. No log line.
6# Discovered by A, next week, as "the API lost my work".
Fenced: the race has a loser who finds out
1A: GET /docs/7ETag W/"v14"
2B: GET /docs/7ETag W/"v14"
3A: PUT /docs/7 If-Match: W/"v14"200, ETag W/"v15"
4B: PUT /docs/7 If-Match: W/"v14"412 Precondition Failed
5 { "error": { "code": "version_conflict",
6 "current_etag": "W/\"v15\"" } }
7B: GET /docs/7merge or promptPUT 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.

  1. 1
    Team → API: skips validators; polling clients re-download full payloads and editors overwrite each other, but staging never notices.
  2. 2
    Mobile team → API: polls a 180KB resource on the app's home screen; bandwidth bills and battery complaints climb.
  3. 3
    Team → 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".
  4. 4
    Team → writes: implements If-Match as optional; the web app sends it, the older mobile app does not and keeps winning races it should lose.
  5. 5
    Users → support: "the app lost my edits" tickets accumulate with no correlated errors — the signature of unfenced writes.
What breaks
  • 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.

Design the contract
  • • 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.
Observe in production
  • • 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.
Evolve without breaking
  • • 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]]).
What it costs
  • • 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.

Misconceptions

Claim
“ETags are a caching optimization — irrelevant if we do not care about bandwidth.”
Reality
The same validator is the standard HTTP fence against lost updates. An API without validators has no protocol-level way for a client to say "only if unchanged" — the concurrency use alone justifies them on any multi-writer resource.
Claim
“A hash of the response body is the correct ETag.”
Reality
Only if serialization is deterministic and byte-identity is the promise you want. JSON key order, embedded timestamps or locale formatting make hash-tags churn on meaningless changes, silently zeroing your 304 rate. A version counter tracks *meaningful* change and costs a column read.
Claim
“304 saves the server the work of handling the request.”
Reality
The server still authenticates, authorizes and determines current state — it saves the body: rendering (sometimes), transfer and client parsing (always). The big beneficiaries are the network and the client; size server expectations accordingly.

Apply it