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
- Conditional requests and 304 responses — the precondition family and the order the origin evaluates them in.
- Strong vs weak ETags explained — why only a strong validator is acceptable here.
- ETag generation and validation strategies — how to produce a validator that changes exactly when the resource does.
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
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)
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.
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-Matchconverts 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.
Related
- Conditional requests and 304 responses covers the read-side preconditions and their evaluation order.
- Strong vs weak ETags explained explains the comparison function this depends on.
- Cache-Control best practices for REST APIs covers the caching contract the same validator supports.