Using If-Match and 412 for Safe Concurrent Writes

Problem Statement

Two people open the same record, both edit it, and both save. The second save silently overwrites the first, and nothing anywhere reports a problem. The validator the API already emits for caching is sufficient to detect this, and the mechanism for acting on it is a precondition the client sends with the write.

Prerequisite Concepts

Step-by-Step Resolution

Step 1 — Return a strong validator on the read

Concurrency control reuses the caching validator, so the read must emit one:

GET /api/documents/8841
HTTP/2 200
ETag: "rev-4172"
Cache-Control: private, no-cache

Deriving the value from a revision counter rather than from the rendered body is usually best here: it changes exactly when the underlying record changes, and it is identical on every server.

Step 2 — Require the precondition on writes

An unsafe request without If-Match is a write with no concurrency check at all. Rejecting it outright is stricter than the specification requires and considerably safer in practice:

PATCH /api/documents/8841
If-Match: "rev-4172"
Content-Type: application/merge-patch+json

{"title": "Revised heading"}
428 Precondition Required   <- when If-Match is absent entirely
412 Precondition Failed     <- when it is present and does not match
The write that is correctly rejectedBoth clients read the same revision; the first write succeeds and changes the validator, so the second write's precondition no longer matches and the update is refused.Client AServerClient B200 + ETag rev-4172200 + ETag rev-4172PATCH, If-Match: rev-4172200, now rev-4173PATCH, If-Match: rev-4172412 Precondition Failed
The second write is rejected instead of silently overwriting

Step 3 — Compare strongly and reject weak validators

If-Match uses strong comparison. A weak validator asserts that two representations are equivalent enough to reuse, which is not a sufficient basis for deciding whether to overwrite one with the other:

supplied = request.headers.get("If-Match")
if not supplied:
    return Response(status=428)
if supplied.startswith("W/"):
    return Response(status=400, body="weak validator not acceptable for If-Match")
if supplied.strip('"') != current_revision(document):
    return Response(status=412)
The status code for each precondition outcomeEach outcome has a distinct status, and conflating them makes client recovery impossible to implement correctly.StatusClient actionPrecondition absent428resend with If-MatchPrecondition matches200 or 204donePrecondition stale412re-read and reconcileWeak validator supplied400request a strong validator
Four outcomes, four distinct status codes

Step 4 — Reject without side effects

A 412 must leave the resource untouched. That sounds obvious and is easy to violate: a handler that validates after beginning a transaction, or after emitting an audit event, has already produced a side effect for a write it is about to refuse. Evaluate the precondition first, before anything observable happens.

Step 5 — Recover on the client

A conflict is a user-facing event, not an error to retry away:

const res = await fetch(url, {
  method: "PATCH",
  headers: { "If-Match": etag, "Content-Type": "application/merge-patch+json" },
  body: JSON.stringify(changes),
});

if (res.status === 412) {
  const current = await fetch(url, { cache: "no-store" });
  const latest = await current.json();
  // Re-read, then merge or prompt — never retry blindly with the new ETag
  showConflict(latest, changes);
}

Retrying with the freshly-read validator and the original payload reproduces exactly the lost update the precondition prevented, which is the single most common mistake in implementing this pattern.

Lost updates per 10000 concurrent editsWithout a precondition every overlapping edit silently overwrites; with If-Match the overlap becomes a visible conflict the user can resolve.No precondition184updates silently overwrittenIf-Unmodified-Since31updates same-second collisionsIf-Match, strong ETag0updates all surfaced as 412the same edit traffic in all three cases
Overlapping edits on a shared document set

Step 6 — Decide what a conflict means to the user

The protocol part of optimistic concurrency is small; the product part is where implementations differ, and skipping it turns a correct 412 into a frustrating experience.

Three responses are reasonable, and which one applies depends on the shape of the data. Automatic merge is right when edits touch disjoint fields — two people changing different columns of the same record have no genuine conflict, and the server can apply both. A prompt is right when the same field changed, because only the user knows which value should win. A hard refusal with a re-read is right when the resource is small enough that redoing the edit is cheap.

What is never right is a silent retry. It is the easiest thing to implement, it makes the 412 disappear from the logs, and it restores exactly the lost-update behaviour the precondition was added to prevent. If conflicts are frequent enough that a prompt feels intrusive, the resource is probably too coarse — splitting it into smaller independently-writable resources reduces overlap far more effectively than any retry policy.

Expected Output / Verification

A correct implementation refuses a stale write, leaves the resource unchanged, and returns the current validator so the client can re-read:

# First write succeeds and changes the validator
curl -sX PATCH -H 'If-Match: "rev-4172"' -d '{"title":"A"}' \
  -o /dev/null -w '%{http_code}\n' https://example.com/api/documents/8841
# 200

# Second write with the old validator is refused
curl -sX PATCH -H 'If-Match: "rev-4172"' -d '{"title":"B"}' \
  -o /dev/null -w '%{http_code}\n' https://example.com/api/documents/8841
# 412

Confirm afterwards that the document still reads A. A 412 that nonetheless applied the change is worse than no precondition at all, because the client now believes the write was rejected.

Edge Cases

  • If-Match: * asserts only that the resource exists. It is useful for guarding against creating a resource that was concurrently deleted, but provides no version check.
  • A validator derived from the rendered response. If the body includes a timestamp or a computed field, the validator changes without the underlying record changing, producing spurious conflicts.
  • Load-balanced servers with different validators. A write routed to a different node than the read compares against a different value, so conflicts appear at random.
  • Intermediaries stripping the header. A proxy that drops If-Match converts every guarded write into an unguarded one, silently removing the protection.
  • Batch writes. A request modifying several resources cannot express one precondition per resource. Either write them individually or move the concurrency check into the payload.
  • Long-lived edit sessions. A validator captured an hour ago will almost always conflict. Re-reading before submission, or using a finer-grained resource, keeps conflicts meaningful rather than routine.

Frequently Asked Questions

Why not use a version field in the request body instead?

You can, and many APIs do. Using If-Match puts the concurrency check in the protocol rather than in the payload, so intermediaries and generic clients understand it, and the same validator serves both caching and concurrency.

Does If-Match require a strong validator?

Yes. A weak validator asserts only semantic equivalence, which is not sufficient when the decision is whether to overwrite one version with another. Weak validators must be rejected for If-Match.

What should a client do after a 412?

Re-read the resource, compare against what the user changed, and either merge automatically or present the conflict. Retrying blindly with the new validator reintroduces the lost update the precondition prevented.

Is If-Unmodified-Since an acceptable alternative?

It works and uses strong date comparison, but it inherits the one-second resolution limit of any date-based validator. Two writes within the same second are indistinguishable, which is exactly the case concurrency control exists for.


Back to Conditional Requests and 304 Responses