Prevent lost updates with ETag and If-Match: optimistic concurrency in OpenAPI
Two clients PUT the same resource and the last write silently wins. The HTTP fix is conditional requests with ETag, If-Match, and a 412 that tells the client to re-fetch and retry. Here is the exact state table and the OpenAPI that makes codegen and AI agents handle conflicts cor
Lost updates are the quietest data-corruption bug in an API. Client A opens an invoice, client B opens the same invoice, A saves a new line item, B saves a corrected address, and B's write overwrites A's line item entirely. Nobody gets an error. The fix is not a lock the client holds for minutes; it is an HTTP conditional request built on ETag and If-Match, and it belongs in the OpenAPI contract.
Optimistic vs pessimistic
| Approach | Mechanism | Good when | Cost |
|---|---|---|---|
| Pessimistic | Server holds a lock until the client releases it | High contention, long edits | Lock expiry, dead clients, stateful server |
| Optimistic | Client sends the version it read; server rejects stale writes | Most CRUD APIs, short edits, mobile and agent clients | One extra round trip on conflict |
Optimistic concurrency is stateless, survives dropped connections, and maps directly onto two HTTP headers. It is the default you should document unless contention is near constant.
An ETag is a version token
Every GET of a mutable resource returns an ETag response header. Its value is an opaque quoted string that changes whenever the representation changes.
ETag: "3f8a91"
- A strong validator,
"3f8a91", changes whenever a byte changes and is safe for conditional updates. - A weak validator,
W/"3f8a", may stay equal for semantically equivalent but byte-different bodies. Do not use a weak tag forIf-Matchon updates.
You do not promise the tag is a hash. It is often a row version number, a updated_at timestamp, or a content hash. Clients must treat it as opaque and send it back verbatim. Never let a client parse or construct one.
The conditional update flow
- The client
GET /invoices/INV-42and remembersETag: "v3". - It edits the body and sends
PUTwithIf-Match: "v3". - The server compares the header to the current version.
- Equal: apply the write, return the new body and a fresh
ETag: "v4". - Different: someone else wrote first; return
412 Precondition Failedand do not touch the resource.
- On
412, the client re-fetches, reconciles, and retries with the new tag.
For create-if-absent, send If-None-Match: *. The server succeeds only if nothing exists at that URL, returning 412 on a duplicate. That removes the create-versus-update race without a lookup-then-write gap.
Modeling it in OpenAPI
paths:
/invoices/{invoiceId}:
parameters:
- $ref: '#/components/parameters/InvoiceId'
get:
operationId: getInvoice
responses:
'200':
description: One invoice
headers:
ETag:
$ref: '#/components/headers/ETag'
content:
application/json:
schema:
$ref: '#/components/schemas/Invoice'
put:
operationId: updateInvoice
parameters:
- name: If-Match
in: header
required: true
schema: { type: string }
example: '"v3"'
description: Version token from the most recent GET. Required for updates.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/InvoiceUpdate'
responses:
'200':
description: Update applied
headers:
ETag: { $ref: '#/components/headers/ETag' }
content:
application/json:
schema: { $ref: '#/components/schemas/Invoice' }
'412':
description: Stale version; re-GET, reconcile, and retry with the new ETag
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'428':
description: If-Match header is required for updates
Reusable headers keep the contract DRY:
components:
headers:
ETag:
schema: { type: string }
description: Opaque version token. Send it unchanged in If-Match on the next write.
Making If-Match required, and answering a missing header with 428 Precondition Required, forces every client into the safe path instead of silently accepting last-write-wins.
The state table worth pinning to the wall
Conditional logic looks fuzzy until you enumerate it. For a PUT with If-Match:
| Header sent | Resource state | Server response |
|---|---|---|
| matches current tag | exists | 200 or 204, new ETag |
| does not match | exists, changed | 412, no write |
* |
exists | 412 |
* |
absent | 201 created |
| omitted | any | 428 (when you enforce it) |
| matches | absent | 412 (the tag cannot match nothing) |
For If-None-Match the polarity flips: a matching tag means "the representation is still current, skip the download" for a GET (304 Not Modified), which gives you free bandwidth savings on top of conflict protection.
Why this matters for AI agents
Autonomous agents retry writes blindly and hold no lock across a long reasoning step. That is exactly the workload optimistic concurrency is built for. A 412 is machine-readable: the agent can re-GET, diff its intended change against the new body, re-apply just that change, and retry with the fresh ETag. A bare 409 Conflict with no version header leaves the agent guessing whether the problem is the body, a uniqueness violation, or a stale write. Put the current tag in the error response or require the client to re-fetch, and the recovery loop becomes deterministic.
What code scanners miss
When you generate OpenAPI from code, concurrency contracts live in middleware, not in the function signature, so a syntax-only scan usually omits them. A reliable scanner traces the version column or @Version field, detects the If-Match guard and the branch that returns 412, and records the ETag response header. If the conflict path is constructed in a shared helper across packages and cannot be proven statically, that response should be reported as a gap, not silently dropped or invented.
Checklist
- Every mutable resource returns a strong
ETagonGET. PUTandPATCHrequireIf-Match; missing headers get428.- Stale tags get
412with a problem body; the resource is never overwritten. - Create-if-absent uses
If-None-Match: *. GEThonorsIf-None-Matchwith304for caching.- The OpenAPI lists
200,412,428, and the current-tag header so clients and agents can implement the retry loop without reading your source.
With that table in the spec, lost updates stop being possible by accident.
Model conditional headers, generate a client that threads the ETag through GET and PUT, and exercise the 412 branch against a mock, all in the browser app. Concurrency rules are part of a broader change-management strategy; the full breaking-change and versioning workflow is covered in the REST versioning and CI diff guide.

