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-Match precondition 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.

Three-way comparison of one record The phone field is unchanged by me but changed by them, so their value is taken automatically. The address field was changed by me but not by them, so my value is kept automatically. The notes field was changed by both to different values, which is a true conflict requiring the user's choice. The email field was changed by neither and stays as it is. Field Base Mine Theirs Merge phone 0161 555 010 0161 555 010 0161 555 019 take theirs address 1 High St 7 Mill Lane 1 High St keep mine notes VIP VIP, prefers email VIP, call before 5pm choose email [email protected] [email protected] [email protected] unchanged

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

  1. 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.
  2. Send the version with every save. If-Match with an ETag, or a version field in the body — the server rejects stale writes.
  3. On 409/412, get the current record. Ideally the server includes it in the response to save a round trip.
  4. 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.
  5. 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.
  6. 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.

Two editors, one record Editor A and editor B both load the customer at version 7. A changes the phone number and saves with If-Match 7; the server accepts and the record becomes version 8. B changes the address and saves with If-Match 7; the server rejects with 412 and returns version 8. B's form computes a three-way merge: the phone change is theirs, the address change is B's, and nothing conflicts. B confirms, and the merged record saves with If-Match 8, becoming version 9 with both changes. Editor A Server Editor B load v7 load v7 PUT phone, If-Match 7 → v8 PUT address, If-Match 7 412 + current v8 merged (phone theirs, address mine), If-Match 8 → v9

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.

The conflict screen, in order A banner at the top explains that the record was changed by someone else while the user was editing, receives focus, and states how many fields need a decision. Below it, an informational list shows changes merged automatically, such as their new phone number and the user's new address. Then each true conflict is a fieldset with the field name as legend and two radio options, keep your change or use their change, each showing the value. Banner (focused) "Updated by someone else while you were editing." "1 field needs your decision." Merged automatically Phone: their new number. Address: your change kept. Needs a decision Notes: keep yours or use theirs? Radio pair, values as text.

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.


Related

← Server Error Reconciliation