Two agents read the same support ticket. One corrects its customer-visible summary. The other changes its priority.
Both send valid updates. Both receive success responses.
The final record has the new priority and the old summary.
No timeout. No duplicate request. One successful write silently erased another.
This is a hypothetical design example. The same race can happen between an agent and a person using an ordinary web form.
Watch the interleaving
Assume the API replaces the editable fields with the submitted document:
| Step | Agent A | Agent B | Stored version |
|---|---|---|---|
| 1 | Reads ticket | 12 | |
| 2 | Reads ticket | 12 | |
| 3 | Saves corrected summary | 13 | |
| 4 | Saves priority using its old copy | 14 |
B's payload still contains the original summary. Each request makes sense when considered alone. Together, they lose A's work.
A unique operation ID would distinguish the two writes, but both are intentional operations. Duplicate suppression does not decide whether B's starting point is still valid.
Make the starting version part of the write
A useful update contract says:
Apply these changes only if the resource still matches the version I read.
For an HTTP API that supports conditional updates, the client can retain the strong ETag returned with a read and supply it in If-Match:
GET /tickets/42
HTTP/1.1 200 OK
ETag: "ticket-42-v12"
Content-Type: application/json
{"summary":"Original summary","priority":"normal"}
A's update:
PUT /tickets/42
If-Match: "ticket-42-v12"
Content-Type: application/json
{"summary":"Corrected summary","priority":"normal"}
After A succeeds, B's different update using the old tag fails the precondition. In this scenario, the server responds with 412 Precondition Failed and leaves A's version intact.
RFC 9110, section 13.1.1 defines If-Match, requires strong entity-tag comparison, and describes its use in preventing lost updates. The tag above is illustrative; clients should treat the server's tag as opaque.
The application must actually support and enforce this contract. Adding a header to a client cannot fix a server that ignores it.
Enforce it where the mutation happens
For a versioned database record, the core operation can look like this PostgreSQL-style statement:
UPDATE tickets
SET summary = $1,
priority = $2,
version = version + 1
WHERE tenant_id = $3
AND id = $4
AND version = $5
RETURNING version;
The version predicate and mutation belong in the same atomic operation. A separate version check followed by an unconditional update reintroduces the race.
This fragment illustrates the concurrency check; the endpoint also needs authorization and validation. No returned row means the conditional write did not happen. It does not, by itself, distinguish a stale version from a missing record or a scope mismatch.
Every writer must follow the version contract, including admin scripts and background jobs. One unconditional writer can bypass the protection.
A conflict needs a decision
The dangerous recovery is:
- Receive a conflict.
- Fetch the latest version token.
- Attach it to the original stale payload.
- Retry.
That sequence can turn a detected conflict back into a lost update. The token becomes newer while the reasoning remains stale.
Instead, reload the current resource and compare it with the original observation and intended change.
In this example, B wanted only to raise the priority. A policy might permit reapplying that narrow change to the current record, preserving A's summary, then submitting it conditionally again.
But different fields do not automatically imply independent decisions. A changed summary might explain why raising the priority is no longer appropriate. Domain rules determine whether to rebase, recompute, or ask for review.
A bounded retry policy also needs a stopping condition. Persistent contention should surface as a conflict the user can understand.
Test the race deliberately
Avoid a test that merely launches two requests and hopes they overlap. Use a barrier or controlled sequence so both clients read version 12 before either writes.
| Test | Expected result |
|---|---|
| A writes, then B submits a different update based on the same old version | B is rejected; A's data remains |
| B reloads and reapplies an allowed narrow change | Both intended changes survive |
| B fetches a new token but reuses its old whole-document payload | Recovery test fails |
| A background writer changes the record | Its write also advances the version |
| Permission is revoked between read and write | Execution is denied independently of version validity |
These are proposed checks, not reported benchmark results.
For rules spanning multiple records, a version check on one row may be insufficient. Protect the full invariant with an appropriate transaction, constraint, or coordination mechanism.
The design question to bring to your next review is simple: what prevents a correct action based on yesterday's view from overwriting today's work?
AI disclosure: This article was generated and published by an AI assistant at the account owner's request. The scenarios are illustrative.
Top comments (0)