The 409 Conflict Was a Stale Version Field, Not a Duplicate Request
Quick answer: A 409 Conflict on an update is almost never "the server is broken." It is the server telling you that the resource changed between the moment you read it and the moment you wrote it. Stop retrying the same request blindly; read the current version, reconcile your change against it, and resubmit with the fresh version token.
We hit this on an admin screen that let two operators edit the same customer record. The first support agent changed an email and saved. Forty seconds later, a second agent, still looking at the record they had opened before the first save, updated the billing address and clicked save. The API returned 409. The second agent's browser surfaced a generic "request failed" toast, so they clicked save again, got the same 409, and filed a ticket claiming the API was flaky. The API was doing exactly its job.
The optimistic-locking contract
Most APIs that allow concurrent edits use optimistic concurrency control. The resource carries a version token that changes on every successful write. The client must send that token back when it updates; if the token no longer matches the stored one, the server rejects the write rather than silently overwriting a change it never saw.
That token shows up in a few common shapes:
- a dedicated
versionnumber that increments on each write; - an
ETagheader, echoed back inIf-Match; - an opaque
revisionorupdatedAttimestamp.
Here is the typical request and rejection.
# Client reads the record first
GET /v1/customers/c_882
# response includes: version 7
PUT /v1/customers/c_882
{ "billingAddress": "12 Market St", "version": 7 }
# If someone else already saved version 8:
409 Conflict
{
"type": "https://example.com/probs/version-conflict",
"title": "Resource version mismatch",
"currentVersion": 8
}
Why a blind retry makes it worse
The tempting move is to treat 409 like a 429 and just retry. That is wrong. Replaying the same body with the same stale version token will fail every time, because the token is the thing that is stale. Worse, if a client forces the write by dropping the version check, it overwrites the intervening change with no audit trail. The table below separates the responses that look similar but mean different things.
| Status | Meaning | Correct client action |
|---|---|---|
| 409 Conflict | The resource changed; your version is stale | Re-read, reconcile, resubmit with new version |
| 429 Too Many Requests | You are sending requests too fast | Wait per Retry-After, then retry |
| 423 Locked | A pessimistic lock is held | Wait for the lock or ask the holder to release |
| 400 Bad Request | Your payload is malformed | Fix the body; retrying unchanged will not help |
Walking the reconciliation loop
The robust flow is a read-modify-write loop that treats a conflict as a signal to merge rather than an exception to throw away.
async function saveWithConflictHandling(client, id, desiredPatch) {
for (let attempt = 0; attempt < 3; attempt++) {
const current = await client.get(`/v1/customers/${id}`);
const merged = { ...current.body, ...desiredPatch };
const res = await client.put(`/v1/customers/${id}`, {
...merged,
version: current.body.version,
});
if (res.status !== 409) return res;
// Loop: re-read on the next iteration and merge against the latest state
}
throw new Error("Could not reconcile after repeated conflicts");
}
Automatic merging is only safe for fields that do not collide. If the server reports that the same field changed in both edits, do not guess; surface a diff to the user. In our support tool, the 409 handler now reloads the record, highlights the field that changed underneath, and asks the operator whether to keep their edit or the newer value. That converts a confusing error into a decision.
Server-side checks worth confirming
When debugging a 409, verify the server is actually comparing versions and not rejecting for a hidden reason. Check the following:
- the read response includes the version token the client is expected to return;
- the update handler compares the supplied token against the stored one atomically;
- the version is incremented only on a successful write, not on reads;
- the error body tells the client the current version so it can avoid an extra round trip.
A compare-and-set that is not atomic can produce false 409s under concurrency or, worse, allow two writes that both believe they hold the latest version. The compare and the update need to happen in one transaction or against a single atomic conditional write.
When the conflict is really a uniqueness clash
Some servers return 409 for a duplicate unique key rather than a stale version, such as creating a second account with the same email. Read the error type or code field before assuming it is optimistic locking. A version-conflict problem needs reconciliation; a unique-violation needs a different user message and no re-read loop. The status code alone is not enough; the error code is what tells you which branch to run.
FAQ
Should I just retry a 409 after a short delay?
No. Unlike a 429, the request itself is stale because its version token no longer matches. Retrying the unchanged body keeps failing. Re-read the resource, merge your change, and resubmit with the current version.
What is the difference between optimistic and pessimistic locking?
Optimistic locking lets anyone open the record and only checks for a collision at write time using a version token. Pessimistic locking acquires a lock up front and blocks other editors until it is released. Optimistic is friendlier for low-contention web UIs; pessimistic fits long, high-contention transactions.
How do I handle two people editing the same field?
Do not auto-merge a colliding field. Reload the latest record, show the user both values in a diff, and let them choose. Automated merge is safe only for non-overlapping fields.
Why does the server sometimes return 409 on a create?
That usually indicates a uniqueness constraint, such as a duplicate email or external ID, rather than a version mismatch. Check the error code and branch to a dedicated "already exists" message instead of running a reconciliation loop.
This is part of the API Debugging Guide — start there for a structured way to read status codes and trace a failing request to its real cause.