Optimistic concurrency for people who have never needed it
Most APIs let you PATCH an object and get on with your day. Then one day two writers touch the same record a second apart, one change vanishes, and nobody can explain where it went. If-Match is the header that turns that silent loss into a visible error.
The lost update, concretely
Two people are editing the same short link. Ana opens it to change the destination. Ben opens it to add a tag. Both load the current state, both submit.
10:00:01 Ana GET /links/lnk_42 → { destination: "/old", tags: [] }
10:00:02 Ben GET /links/lnk_42 → { destination: "/old", tags: [] }
10:00:09 Ana PATCH { destination: "/new" } → 200
10:00:14 Ben PATCH { tags: ["launch"] } → 200
final state: { destination: "/new", tags: ["launch"] } ✓ fine
That case works, because the two PATCHes touched different fields. Now make Ben's client send the whole object back, as clients routinely do when a form was populated from a GET:
10:00:14 Ben PATCH { destination: "/old", tags: ["launch"] } → 200
final state: { destination: "/old", tags: ["launch"] } ✗ Ana's change is gone
Ben overwrote a field he never intended to touch, using data that was already stale when his form loaded. Both requests returned 200. Nothing in any log looks wrong. Ana finds out when a customer reports the old page.
This is the lost update problem, and it is old news in database circles. It reaches ordinary product teams the moment a second writer appears — a colleague, a cron job, a CI step, or an AI agent.
Why agents make this urgent
Human collisions are rare because humans are slow and coordinate socially. You notice a colleague editing the same row. An agent has neither property.
An assistant asked to “retag every link pointing at the old pricing page” will read a list, reason for a few seconds, and then issue writes based on a snapshot that is now stale. If someone edited one of those links in the meantime, a full-object write silently reverts them. Worse, the agent will report success, because it got a 200, and its summary to the user will be confidently wrong.
A model also cannot recognise that it clobbered something. It has no memory of the field it did not intend to send. The only defence is a server that refuses the write.
How optimistic concurrency works
The idea is simple enough to state in one sentence: state the version you believe you are editing, and let the server reject you if you are wrong.
Every object carries a version. On c1k.me it is a plain incrementing integer, returned as version on every read. Other APIs use an ETag string; the mechanics are identical. You send it back in If-Match when you write.
GET /links/lnk_42
→ 200 { "data": { "destination_url": "/old", "version": 3 } }
PATCH /links/lnk_42
If-Match: 3
{ "destination_url": "/new" }
→ 200 { "data": { "destination_url": "/new", "version": 4 } }
# someone else, still holding version 3
PATCH /links/lnk_42
If-Match: 3
{ "tags": ["launch"] }
→ 412 Precondition Failed
It is called optimistic because it does not lock anything. Nobody is blocked, nobody holds a transaction open across a user's coffee break, and there is no lock to leak if a client crashes. The cost is that the loser of a race has to redo their work — which is exactly right, because they now know something changed underneath them.
412 and 428, and the difference between them
These two status codes are the whole vocabulary, and they are routinely confused.
428 Precondition RequiredYou did not send If-Match at all. This is a programming error, not a race. It means your client is capable of blind overwrites, and the server would rather fail loudly now than let you discover that in production. Fix the client; never work around it.
412 Precondition FailedYou sent a version, and it is no longer current. Somebody else wrote first. This is not an error in your code; it is the system working. Re-read, re-apply your intent to the fresh state, and try again.
The retry loop, written correctly
The shape that matters: re-read, recompute the change from the fresh state, write with the fresh version, and give up after a small number of attempts.
async function mutate(id, apply, attempts = 3) {
for (let i = 0; i < attempts; i++) {
const { data: current } = await call(`/links/${id}`);
// apply() receives the CURRENT state and returns only the delta.
// This is the part people get wrong: the change must be
// recomputed each attempt, not captured once outside the loop.
const patch = apply(current);
const res = await fetch(`${BASE}/links/${id}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
"If-Match": String(current.version)
},
body: JSON.stringify(patch)
});
if (res.ok) return (await res.json()).data;
if (res.status !== 412) throw new Error(`write failed: ${res.status}`);
// 412: someone won the race. Loop and rebuild from their result.
}
throw new Error("too much contention on " + id);
}
// safe: the tag list is derived from whatever state we just read
await mutate("lnk_42", cur => ({ tags: [...new Set([...cur.tags, "launch"])] }));
Three details are load-bearing. The callback takes the current state, so a merge is recomputed against the winner's version rather than the loser's. Only 412 loops — every other failure throws, because retrying a 422 or a 403 is pointless. And the attempt count is small: persistent contention on one object is a design problem, and hiding it behind twenty retries makes it harder to find.
Send only the fields you are changing. A PATCH of { tags: [...] } cannot revert a destination even if your read was slightly stale. Optimistic concurrency protects you from the collisions you did not anticipate; minimal payloads protect you from the ones you did.
What to show a human when this happens
Automatic retry is right for machine writers making mergeable changes. It is usually wrong for a person who has typed something into a form, because the safe merge is not something software can infer — you cannot combine two different destination URLs.
Tell them plainly what happened, who did it, and what their options are. “Jana changed this link 40 seconds ago. Your version sets the destination to /new; hers sets it to /launch-v2.” Then offer to overwrite or to discard. Never silently discard their typing, and never silently overwrite the other person.
For agents, the equivalent is making the 412 visible in the tool result rather than swallowing it. An assistant that reports “three links updated, one skipped because it changed while I was working” is far more useful than one that reports four successes.
Not the same thing as idempotency
These two get conflated constantly, and they solve opposite problems.
A robust client uses both, on different verbs. There is a separate piece on idempotency keys if that is the half you need.
Read the version, send it in If-Match, send only the fields you are changing. Treat 428 as a bug in your client and 412 as normal life. When you retry a 412, rebuild the change from the state you just re-read rather than replaying the body you already had. And surface the conflict to whoever caused it, human or model, instead of resolving it behind their back.