The exact problem: a reader returns to a form, a saved draft is found, and the record on the server has also changed since that draft was written. Restoring the draft silently discards someone’s edit; discarding the draft silently loses the reader’s work. Both are data loss, and only one of them will be reported.

Context and Prerequisites

This assumes drafts already exist, whether in device storage as described in autosaving form drafts to localStorage or on the server as described in draft persistence and autosave. The reconciliation below applies to both — the second tab is as real a concurrent editor as a colleague.

Three timestamps decide everything, and a draft system that does not record all three cannot reconcile at all:

  • baseVersion — what the record looked like when the draft started. Usually a version number or ETag, not a clock.
  • draftSavedAt — when the reader last touched the draft.
  • remoteUpdatedAt — when the server copy last changed.

The Four Cases

Comparing the draft’s baseVersion against the server’s current version, and checking whether the draft actually differs from its base, yields four cases. Only one of them is a genuine conflict.

Only one of the four cases is a conflict If the draft matches its base and the remote has not moved, there is nothing to reconcile and nothing to say. If the draft matches its base but the remote has moved, the draft carries no unsaved work, so it is discarded and the fresh remote copy is loaded silently. If the draft differs from its base and the remote has not moved, the draft can be applied directly — a clean fast-forward, which is the common case after a reload. Only when the draft differs from its base and the remote has also moved is there a genuine conflict, and that is the one case where the reader has to be asked. remote unchanged remote moved on draft == its base draft has changes nothing to do no unsaved work, no divergence say nothing at all discard the draft, load remote the draft carried nothing the reader typed silent — there is nothing to lose fast-forward apply the draft, announce it the common case after a reload a real conflict — ask both sides changed since the base the only case worth a dialog Systems that prompt on every restore are collapsing all four cases into the fourth, which trains readers to dismiss the prompt.

Core Pattern

type Values = Record<string, unknown>;

interface Draft { baseVersion: string; savedAt: number; values: Values; }
interface Remote { version: string; updatedAt: number; values: Values; }

type Reconciliation =
  | { kind: 'none' }
  | { kind: 'take-remote'; values: Values }
  | { kind: 'take-draft'; values: Values }
  | { kind: 'conflict'; fields: FieldDiff[]; draft: Draft; remote: Remote };

interface FieldDiff {
  field: string;
  base: unknown;
  draft: unknown;
  remote: unknown;
  /** True when both sides changed this field to DIFFERENT values. */
  contested: boolean;
}

export function reconcile(draft: Draft, remote: Remote, base: Values): Reconciliation {
  const draftChanged = changedFields(base, draft.values);
  const remoteMoved = draft.baseVersion !== remote.version;

  if (draftChanged.length === 0) {
    // The draft carries nothing the reader typed. Whatever the remote says wins.
    return remoteMoved ? { kind: 'take-remote', values: remote.values } : { kind: 'none' };
  }
  if (!remoteMoved) {
    // Nobody else touched it — a clean fast-forward, no question needed.
    return { kind: 'take-draft', values: draft.values };
  }

  // Both moved. Compute a per-field diff so the question can be specific.
  const remoteChanged = changedFields(base, remote.values);
  const touched = new Set([...draftChanged, ...remoteChanged]);
  const fields: FieldDiff[] = [...touched].map((field) => ({
    field,
    base: base[field],
    draft: draft.values[field],
    remote: remote.values[field],
    // Only fields BOTH sides changed, to different values, are actually contested.
    contested: draftChanged.includes(field) && remoteChanged.includes(field) &&
      !Object.is(draft.values[field], remote.values[field]),
  }));

  // A "conflict" where no field is contested is a merge, not a decision:
  // take each side's change to the fields only it touched.
  if (!fields.some((f) => f.contested)) {
    const merged: Values = { ...base };
    for (const f of fields) {
      merged[f.field] = remoteChanged.includes(f.field) ? f.remote : f.draft;
    }
    return { kind: 'take-draft', values: merged };
  }
  return { kind: 'conflict', fields, draft, remote };
}

const changedFields = (a: Values, b: Values): string[] =>
  [...new Set([...Object.keys(a), ...Object.keys(b)])]
    .filter((k) => !Object.is(normalise(a[k]), normalise(b[k])));

The non-contested merge is what stops this being annoying in practice. Two people editing the same record usually edit different fields — one updates the address, the other the phone number — and asking the reader to choose between two whole documents when the changes do not overlap is a question with an obvious answer that you made them answer anyway.

Step-by-Step Walkthrough

  1. Record the base. Store the version the draft started from. Without it there is no way to tell “the reader changed this” from “it was always like that”.

  2. Normalise before comparing. Trim, coerce and treat empty as null on both sides, exactly as in dirty and pristine state tracking. A trailing space must not create a conflict.

  3. Short-circuit the three easy cases. Most restores are none or take-draft. Handling them silently is what earns the right to interrupt for the fourth.

  4. Merge the non-contested fields. Only fields both sides changed, to different values, need a decision.

  5. Ask about fields, not documents. “Keep mine / keep theirs” on the whole record forces the reader to lose something. Per-field choice usually lets them lose nothing.

  6. Re-base after resolving. The merged result’s base becomes the remote’s current version, or the very next save conflicts again.

Failure Modes and Edge Cases

1. Clocks instead of versions

draftSavedAt > remoteUpdatedAt is not a valid ordering. Device clocks are wrong, sometimes by hours, and two devices need not agree. Use a version, an ETag or a monotonic sequence from the server; use timestamps only for prose the reader reads.

2. The base was never stored

Retrofitting drafts onto an existing form usually means early drafts have no baseVersion. Treat a missing base as “assume everything in the draft is a change” — that over-reports conflicts, which is the safe direction — and let the next save write a proper base.

3. Structural changes

A field renamed or removed between the draft being written and restored will show as a change on both sides. Versioning the payload catches the incompatible cases; for compatible ones, drop unknown keys on load rather than presenting a conflict about a field that no longer exists.

4. Resolving into a stale base

If the reader resolves a conflict and the remote moves again before they save, the save conflicts once more — which is correct, but infuriating if the second conflict is presented as if the first never happened. Carry the resolved values forward and re-run reconcile against the new remote; usually the second pass is a clean merge.

5. The conflict dialog is inaccessible

A modal that appears without moving focus, without a heading, and without an announcement is invisible to a screen reader reader — who will then continue typing into a form that is about to be overwritten. Everything in focus management after validation applies, and the stakes are higher than a validation error.

Ask about the one field that is actually contested Three fields are shown with their base value, the reader's draft value and the current remote value. The phone field was changed only in the draft, so the draft value is taken automatically. The job title was changed only on the remote, so the remote value is taken automatically. The address was changed on both sides to different values, so it is the only row the reader is asked about — and the question is about one field rather than about the whole record. Field base your draft theirs outcome phone 0161 … 0161 496 … unchanged yours, silently job title Engineer unchanged Lead Engineer theirs, silently address 12 Mill St 14 Mill St 12 Mill Street you choose One question, about one field, with both values visible — instead of "keep mine or keep theirs" over the whole record. Show the base too: it is what makes "12 Mill Street" recognisable as a formatting fix rather than a different address. After resolving, re-base to the remote's current version — otherwise the very next save conflicts again.

And the three states the conflict view itself can be in, each of which needs a decided behaviour:

The conflict view is a state, not a moment Presented: the view has focus, both values are visible with the base for reference, and nothing has been written yet. Resolved: the reader chose per field, the merged values are applied, and the base is moved forward to the current remote version so the next save does not conflict again. Dismissed: nothing was chosen, so nothing is written, the form stays flagged, and the conflict remains reachable from a persistent control rather than being silently resolved by whichever side happened to be in state. The conflict view is a state, not a moment presented has focus, shows the base nothing written yet resolved merged values applied, base moved forward dismissed nothing written, still flagged, reachable again Dismissal must not pick a side — readers dismiss by reflex, and a reflex should not decide whose edit survives.

Verification Checklist


Related

Draft Persistence and Autosave

Frequently Asked Questions

Can conflicts be merged automatically?

Non-contested ones, yes — if the two sides changed different fields, taking each side’s change is unambiguous and asking the reader is a question with only one sensible answer. Contested fields, where both sides changed the same field to different values, cannot be merged safely: there is no rule that reliably picks the right one, and ‘last write wins’ is just automatic data loss with better branding. Ask, and ask about the field rather than the record.

What if the reader dismisses the conflict view?

Treat dismissal as ‘do nothing yet’ rather than as a choice. Keep the draft, keep the remote, leave the form in a read-only or clearly-flagged state, and make the conflict reachable again from a persistent control. A dismissal that silently picks a side is the same data loss you built the view to prevent, and readers dismiss things by reflex.

Do device-local drafts need conflict handling at all?

Yes, because a second tab is a concurrent editor. Two tabs on the same origin share the same storage key, so the second tab’s write overwrites the first with no signal unless you listen for storage events. The reconciliation is the same shape as the server case; only the source of the competing copy differs.

Should the base version be a timestamp or a version number?

A version number, an ETag or a monotonic sequence issued by the server. Device clocks are unreliable and two devices need not agree, so a timestamp comparison can order two edits backwards. Keep timestamps for what you show the reader — ‘saved 12 minutes ago’ — and use the version for every decision.