writing/24 Aug 2026/11 min read

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 Required

You 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 Failed

You 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.

Never retry a 412 with the same version. It will fail identically, forever. And never “fix” a 412 by fetching the new version and resending your original full body — that is the lost update again, wearing a header.

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.

If-Match / versions
Idempotency-Key
Stops two different writers from clobbering each other.
Stops one writer retrying from duplicating itself.
Applies to updates and deletes of existing objects.
Applies to creates, where there is no version to reference yet.
Failure mode without it: a change silently disappears.
Failure mode without it: two identical objects appear.

A robust client uses both, on different verbs. There is a separate piece on idempotency keys if that is the half you need.

The short version

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.