Two people open the same customer record; one fixes the phone number, the other updates the address; the second save silently overwrites the first, and the corrected phone number is gone — no error, no warning, just lost work that someone will rediscover weeks later.
Optimistic concurrency control prevents the silent overwrite: each save states which version it was based on, and the server rejects saves based on a stale version with 409 Conflict (or 412 Precondition Failed when using If-Match). The form then has a new job — show the user what changed, keep their edits, and let them resolve the difference. This page, part of server error reconciliation, implements both halves.
Context and prerequisites
The server side, briefly:
- Every record carries a version (an integer incremented on each write) or an ETag (a hash of the representation).
- The client sends the version it loaded — in the body, or as
If-Match: "<etag>". - The server performs the write only if the stored version still matches (
UPDATE … WHERE id = ? AND version = ?), otherwise returns 409 (version in body) or 412 (If-Matchprecondition failed), ideally with the current record in the response.
The client side needs three snapshots to resolve a conflict:
- Base — what the user’s form was loaded from.
- Mine — what the user wants to save.
- Theirs — what the server has now.
Comparing each field across the three gives a precise, per-field picture: changed only by me, changed only by them, changed by both.
The core pattern: version-guarded save and a three-way merge
type Rec = Record<string, unknown> & { id: string; version: number };
export type MergeField = { field: string; base: unknown; mine: unknown; theirs: unknown;
resolution: "mine" | "theirs" | "conflict" | "same" };
const eq = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
export function threeWay(base: Rec, mine: Rec, theirs: Rec): MergeField[] {
const fields = new Set([...Object.keys(base), ...Object.keys(mine), ...Object.keys(theirs)]);
fields.delete("id"); fields.delete("version");
return [...fields].map((field) => {
const [b, m, t] = [base[field], mine[field], theirs[field]];
const iChanged = !eq(b, m), theyChanged = !eq(b, t);
const resolution =
!iChanged && !theyChanged ? "same" :
iChanged && !theyChanged ? "mine" :
!iChanged && theyChanged ? "theirs" :
eq(m, t) ? "same" : "conflict"; // both changed to the same value: no conflict
return { field, base: b, mine: m, theirs: t, resolution };
});
}
export async function saveWithConcurrency(base: Rec, mine: Rec) {
const res = await fetch(`/api/customers/${mine.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json", "If-Match": `"${base.version}"` },
body: JSON.stringify(mine),
});
if (res.status === 409 || res.status === 412) {
// Prefer the server's current record in the response; fetch it if absent.
const theirs: Rec = (await res.json().catch(() => null))?.current
?? (await (await fetch(`/api/customers/${mine.id}`)).json());
const merge = threeWay(base, mine, theirs);
// Auto-resolvable fields are pre-applied; true conflicts need a decision.
const proposed: Rec = { ...theirs };
for (const f of merge) if (f.resolution === "mine") proposed[f.field] = f.mine;
return { status: "conflict" as const, theirs, merge, proposed };
}
if (!res.ok) throw new Error(`Save failed: ${res.status}`);
return { status: "saved" as const, record: (await res.json()) as Rec };
}
The conflict UI shows a banner (“This customer was updated by someone else while you were editing”), lists auto-merged changes for information, and for each true conflict offers both values with a clear choice. Saving the resolution sends proposed (with the user’s choices applied) based on theirs.version — so a third concurrent change is also detected.
Step-by-step walkthrough
- Load the record with its version or ETag, and keep that base snapshot. The base is what makes a three-way comparison possible; without it you can only show “yours vs theirs” and cannot tell who changed what.
- Send the version with every save.
If-Matchwith an ETag, or aversionfield in the body — the server rejects stale writes. - On 409/412, get the current record. Ideally the server includes it in the response to save a round trip.
- Compute a per-field three-way merge. Fields changed on one side only are resolved automatically; fields changed on both sides to different values are real conflicts.
- Present conflicts field by field, keep the user’s edits. Never discard “mine”. Show both values side by side with labels (“Your change” / “Their change”), and pre-select nothing for true conflicts.
- Save the resolution against the new version. The next save is itself version-guarded, so if a third person edits meanwhile, the process repeats rather than overwriting them.
Why “last write wins” is not a neutral default
Without concurrency control, the second save silently wins, and it feels harmless because nothing errors. But the loss is real, invisible and discovered late — a corrected phone number reverts, a cancelled order is re-enabled. The cost of optimistic concurrency is small: a version column and a WHERE clause on the server, and a conflict screen that most users rarely see. The cost of skipping it is data loss that looks like user error. For any form editing shared records — CRM entries, tickets, settings pages multiple admins use — version checks should be the default, and the conflict screen part of the design from the start.
Failure modes and edge cases
1. Comparing only two versions
“Yours vs theirs” without the base cannot distinguish “they changed the phone” from “you changed it back”. Users then have to re-decide every differing field, including ones only the other person touched.
2. Treating 409 as a validation error
A conflict is not a problem with the user’s input. Do not mark fields invalid or show red errors; use a distinct, calm conflict state, as in the routing from problem details (RFC 9457) for form errors.
3. Arrays and nested objects
Per-field comparison of whole arrays reports a conflict whenever both sides edited any line item. For repeatable groups, merge per row by row id, and per field within each row, using the identity model from dynamic field arrays and repeatable groups.
4. Autosave and conflicts
Autosave makes conflicts frequent and invisible. When an autosave gets 409, stop autosaving, show the conflict banner, and let the user resolve before continuing — similar to the cross-tab lease in syncing drafts across tabs with BroadcastChannel.
5. Accessibility of the merge UI
Conflict choices are radio groups per field (“Keep your change” / “Use their change”) with the values as text, inside a fieldset whose legend names the field. Move focus to the conflict banner when it appears, and announce the number of conflicts.
Verification checklist
Frequently Asked Questions
Should I lock the record while someone is editing instead?
Pessimistic locks prevent conflicts but create stale locks when people close tabs, and block colleagues for the length of an edit. For most forms, optimistic concurrency with a good conflict screen is less disruptive; locks suit long, exclusive workflows.
ETag and If-Match, or a version field in the body?
Both work. ETags with If-Match are the HTTP-native approach and fit REST APIs with caching; a version field is simpler with RPC-style endpoints and GraphQL. The client logic is the same either way.
Can I show who made the other change?
If the server includes updatedBy and updatedAt in the current record, show them (“Updated by Grace at 14:32”) — it helps users decide whether to keep their version or talk to their colleague first.