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.
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
-
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”.
-
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.
-
Short-circuit the three easy cases. Most restores are
noneortake-draft. Handling them silently is what earns the right to interrupt for the fourth. -
Merge the non-contested fields. Only fields both sides changed, to different values, need a decision.
-
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.
-
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.
And the three states the conflict view itself can be in, each of which needs a decided behaviour:
Verification Checklist
Related
- Draft Persistence and Autosave — the lifecycle that produces the conflict phase
- Autosaving Form Drafts to localStorage — detecting the second tab
- Dirty and Pristine State Tracking — the normalisation the comparison depends on
← 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.