Optimistic Concurrency: Versions and If-Match
Let concurrent writers proceed without locks, but make every update state which version it read. A stale version gets a 409 or 412 instead of silently destroying someone else's write — and the contract must say who untangles the conflict.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
The mechanism: a version travels with the read
Every read carries the resource's current version — an ETag header, a version integer in the body, or both. Every write sends that version back: If-Match: "v7" as a precondition, or "version": 7 in the payload. The server compares atomically at write time: match → apply and bump; mismatch → reject with 412 Precondition Failed (the HTTP-native form) or 409 Conflict (the body-version form). Nothing locks, nothing waits — hence *optimistic*: writers proceed assuming no conflict and pay only when one actually happened.
The comparison must be atomic with the write — a WHERE version = 7 on the UPDATE, or a compare-and-set in the store — not an application-level read-check-write, which just narrows the race window without closing it. Databases give you this primitive cheaply (Concurrency Anomalies covers what happens underneath); the API's job is to surface it as a contract clause rather than absorb the anomaly silently.
Optimistic beats pessimistic (lock on read) for API use almost by forfeit: HTTP clients disappear without unlocking, hold locks across human think-time, and retry — every one of those behaviors poisons lock-based schemes. Pessimistic concurrency survives inside a transaction on one connection; it does not survive being stretched across stateless requests. If a workflow truly needs exclusive access for minutes, model the *claim* explicitly (a checkout/lease resource with an expiry) so the lock is visible, owned and reclaimable, instead of implied.
PUT /articles/42 HTTP/1.1
If-Match: "v7"
Content-Type: application/json
{ "title": "Q3 Plan", "body": "…edited from v7…" }HTTP/1.1 412 Precondition Failed
ETag: "v9"
Content-Type: application/json
{
"error": {
"code": "version_conflict",
"message": "Resource changed since your read (v7 → v9).",
"current_version": "v9",
"request_id": "req_3ab…"
}
}The contract decision: who resolves the conflict
Rejecting the stale write is the easy half. The client now holds changes it cannot save — and the contract must say what happens next, because "show an error" pushed onto an unprepared client becomes "user loses ten minutes of edits", which is barely better than the lost update you prevented.
There are three honest resolution assignments. The user resolves: the client re-fetches, shows a diff or a "this changed while you edited" screen, and the human merges — right for documents and rich edits, and it requires UI investment the API team must warn clients about up front. The client resolves automatically: re-fetch, re-apply the change to the fresh state, retry with the new version — right when the change is a pure function of intent ("set status to approved") rather than of the stale snapshot; this loop is so common the docs should spell it out as the recommended retry recipe. The server resolves: field-level merge of non-overlapping changes — powerful and dangerous, because "non-overlapping" is a semantic judgment (two fields can be logically coupled), so it should be opt-in per field group, not a default.
One structural lever shrinks the conflict surface more than any resolution policy: narrower writes. A full-document PUT conflicts with every concurrent change; a PATCH of two fields, or a command like POST /articles/42/publish, conflicts only with changes it semantically overlaps (PUT vs PATCH, Designing State Transitions). The less state a write claims to know, the fewer 412s anyone has to resolve.
1PUT /articles/42 If-Match: "v7"2→ 412 Precondition Failed3{ "error": "conflict" }4 5# Client team, on discovering this in production:6# - no current_version in the error7# - no guidance: refetch? retry? merge?8# - ships: on 412 → refetch → resend user's9# payload with fresh If-Match10# Result: an auto-overwrite loop. The 412 is now11# a lost update with extra steps.1PUT /articles/42 If-Match: "v7"2→ 412 { code: "version_conflict",3 current_version: "v9",4 retry: "refetch, reapply intent, resend" }5 6Docs, per endpoint:7 status: auto-retry safe — change is intent-based8 ("publish"), reapply and resend9 body: user-mediated — refetch, present merge UI10 tags: server-merged — non-overlapping PATCHes11 are combined, overlap → 412A 412 without a resolution path trains client teams to write blind refetch-and-resend loops, which reintroduce the exact overwrite the version check exists to prevent. Naming the resolution per field group makes the safe loop the easy one.
ETag vs version field, and choosing preconditions
The ETag/If-Match pair is the HTTP-native mechanism: it composes with Conditional Requests: ETags, 304 and 412 caching (If-None-Match reads and If-Match writes share the same token), intermediaries understand it, and 412 has one unambiguous meaning. Its friction is practical: clients must thread a header through layers that mostly handle bodies, and debugging tools show bodies more readily than headers. A version field in the representation is more visible and serializes naturally into client state; it costs you a custom 409 contract that you must document as carefully as HTTP documents 412. Many mature APIs ship both, backed by the same underlying counter.
Whichever token you pick, decide its granularity honestly. A per-resource version is simple and over-conflicts (any change bumps it, so unrelated edits collide). Per-field or per-section versions conflict precisely but multiply bookkeeping. Start per-resource; split only where measured conflict rates on genuinely independent fields justify it.
Also decide whether unconditioned writes remain legal. Accepting a bare PUT without If-Match keeps old clients working — and silently keeps last-write-wins for exactly the clients most likely to cause conflicts. Requiring the precondition (reject bare writes with 428 Precondition Required) is the safe endpoint's posture; the migration between the two is an API Migration: Running the Change End to End with telemetry on who still writes blind.
- `ETag` + `If-Match` — standard, cache-coherent, proxy-friendly; token lives in headers.
- Body `version` + `409` — visible, easy to persist in client state; semantics are yours to document.
- `428 Precondition Required` — the endpoint's way of saying "blind writes are not a thing here".
- Weak vs strong ETags — concurrency control needs strong ones; a weak ETag (
W/"…") says "equivalent", not "identical", and preconditions on it are undefined ground. - Version tokens are opaque — clients that parse or arithmetic them (
v7 + 1) break the day you switch to content hashes.
Key points
- Optimistic concurrency = version travels with the read, write states its precondition, server compares atomically, mismatch is an explicit 412/409.
- The comparison must be compare-and-set at the store (
WHERE version = ?), not an application-level check — otherwise the race merely narrows. - Rejecting the stale write is half the design; the contract must assign resolution — user merge, client reapply-and-retry, or server field merge.
- A blind refetch-and-resend loop on 412 reintroduces the lost update; document the safe retry recipe so clients don't invent the unsafe one.
- Narrower writes (PATCH, commands) shrink the conflict surface more than any resolution policy.
- Locks don't survive stateless HTTP; if exclusivity is truly needed, model the lease as an explicit, expiring resource.
Lost Update Lab
Change the contract and observe which guarantee moves.
—
Without the version check, the second save silently erases the first — a lost update. With it, the stale writer gets 412 and must re-read; the contract turned a data-loss bug into a visible conflict the client can resolve.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → API: adds
versionto responses and a check in the handler — as a read-then-write in application code. - 2Two clients → API: read v7 simultaneously; both checks pass in the race window; the second write silently wins anyway.
- 3Team → API: fixes the atomicity, ships bare
412 conflictwith no current version and no guidance. - 4Client team → users: implements refetch-and-resend on 412 to "make the errors go away"; overwrites resume, now invisible to metrics.
- 5Support → both teams: "my changes disappeared" tickets continue; everyone believes the version check made it impossible.
- Users lose edits — either silently (races, blind-resend loops) or loudly (412s that discard their work because the client had no merge path).
- Sync-style clients (offline mobile) corrupt data confidently: their queued writes carry stale versions, and mishandled conflicts multiply across the queue.
- Trust in the mechanism itself: after one bad 412 experience, client teams route around it with
force=true-style escapes, and the API ends up with documented last-write-wins.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Implement the version check as compare-and-set in the store; return `412`/`409` with the current version and a machine-readable `version_conflict` code.
- • Assign conflict resolution per endpoint or field group in the docs — auto-retry-safe, user-mediated, or server-merged — and spell out the safe retry recipe.
- • Require preconditions on conflict-prone endpoints (`428` for blind writes), migrating existing clients with telemetry rather than by surprise.
- • Prefer intent-shaped writes (PATCH, commands) over full-document PUT to shrink what each write claims to know.
- • Track 412/409 rates per endpoint and per client: a spike is contention (or a client with a broken version cache); a rate of exactly zero on a multi-writer resource means blind writes are still getting through.
- • Log both versions on every conflict (sent vs current) — the gap size distinguishes slow humans from broken clients replaying ancient state.
- • Watch for refetch-immediately-resend patterns in traces: that is the unsafe auto-overwrite loop announcing itself.
- • Ship versions in responses first (additive), let clients adopt `If-Match` voluntarily, then require preconditions per endpoint with notice — each step is compatible.
- • Switching version representation (counter → content hash) survives only if clients treated tokens as opaque; that opacity clause must be in the contract from day one.
- • Server-side merge can be introduced later as an opt-in per field group without disturbing clients that resolve manually.
- • Conflicts move from silent to visible — which means client teams must build handling they previously didn't know they needed; the API team pays in documentation and advocacy.
- • High-contention resources pay a retry tax: under real concurrency, some writers loop refetch-reapply several times; a queue or a command log fits those hotspots better.
- • Requiring preconditions raises the integration floor: quick scripts and one-off tools now need a read before every write.