This page handles the multi-tab failure mode of the workflow in Draft Auto-Save & Recovery Workflows, where a single editor’s snapshot logic is otherwise sound.
The exact problem #
A writer opens a post in two tabs — one to check an earlier section, one to write. They forget which is which and edit in both. Both tabs debounce-save to the same IndexedDB key. The second save overwrites the first.
Nothing errors. Nothing warns. The next time they open the document, one tab’s last four minutes of writing simply is not there. Because auto-save is silent by design, the loss is discovered long after the session that caused it, and there is no undo history that spans tabs.
Version vectors instead of timestamps #
Each tab is a writer with an id and a monotonic counter. A draft record carries the vector of everything its author had seen when they wrote it.
// vector.ts
export type Vector = Record<string, number>; // writerId → counter
export const TAB_ID = crypto.randomUUID();
export function bump(vector: Vector, writer: string): Vector {
return { ...vector, [writer]: (vector[writer] ?? 0) + 1 };
}
export type Relation = 'same' | 'ahead' | 'behind' | 'diverged';
export function compare(a: Vector, b: Vector): Relation {
let aGreater = false;
let bGreater = false;
for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
const av = a[key] ?? 0;
const bv = b[key] ?? 0;
if (av > bv) aGreater = true;
if (bv > av) bGreater = true;
}
if (aGreater && bGreater) return 'diverged'; // a real conflict
if (aGreater) return 'ahead';
if (bGreater) return 'behind';
return 'same';
}
The write path compares inside the same transaction as the put, which is what makes the check race-free:
// saveDraft.ts
export async function saveDraft(db: IDBDatabase, docId: string, content: string, localVector: Vector) {
const nextVector = bump(localVector, TAB_ID);
return new Promise<SaveOutcome>((resolve, reject) => {
const tx = db.transaction('drafts', 'readwrite');
const store = tx.objectStore('drafts');
const readReq = store.get(docId);
readReq.onsuccess = () => {
const stored = readReq.result as DraftRecord | undefined;
if (stored) {
const relation = compare(nextVector, stored.vector);
if (relation === 'diverged') {
// Do NOT overwrite. Park our branch alongside theirs.
store.put({
...stored,
id: `${docId}#conflict-${TAB_ID}`,
content,
vector: nextVector,
conflictOf: docId,
updatedAt: Date.now(),
});
tx.oncomplete = () => resolve({ status: 'conflict', theirs: stored, mine: { content, vector: nextVector } });
return;
}
if (relation === 'behind') {
// Another tab is strictly ahead: adopt theirs rather than clobber.
tx.oncomplete = () => resolve({ status: 'stale', theirs: stored });
return;
}
}
store.put({ id: docId, content, vector: nextVector, updatedAt: Date.now() });
tx.oncomplete = () => resolve({ status: 'saved', vector: nextVector });
};
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(new Error('draft save aborted'));
});
}
The merge UI #
Detection is worth nothing if the resolution is a dialog reading “A conflict occurred”. Show what actually differs.
// DraftConflict.tsx
export function DraftConflict({ mine, theirs, onResolve }: Props) {
const diff = useMemo(() => summariseDiff(theirs.content, mine.content), [mine, theirs]);
return (
<section role="alertdialog" aria-labelledby="conflict-title">
<h2 id="conflict-title">This draft was edited in another tab</h2>
<p>
Both versions are saved. {diff.addedWords} words are only in this tab, and{' '}
{diff.removedWords} words are only in the other. Nothing has been deleted.
</p>
<div className="conflict-panes">
<article aria-label="This tab"><DraftPreview content={mine.content} highlight={diff.onlyMine} /></article>
<article aria-label="Other tab"><DraftPreview content={theirs.content} highlight={diff.onlyTheirs} /></article>
</div>
<button type="button" onClick={() => onResolve('mine')}>Keep this tab’s version</button>
<button type="button" onClick={() => onResolve('theirs')}>Keep the other tab’s version</button>
<button type="button" onClick={() => onResolve('both')}>Keep both, side by side</button>
</section>
);
}
“Keep both” is the option that makes this safe to ship: it appends the losing branch as a clearly marked block at the end of the document rather than discarding it. Users who pick it can delete what they do not want, which is a recoverable action; a wrong choice between “mine” and “theirs” is not.
| Field type | Safe to auto-merge | Strategy |
|---|---|---|
| Independent scalar fields (title, status) | Yes | Take the value from the branch that changed it |
| Set-like fields (tags, labels) | Yes | Union, then dedupe |
| Counters | Yes | Sum the per-writer deltas |
| Prose both branches edited | No | Present both; let the user choose |
| Ordered lists both branches reordered | No | Present both |
| Binary attachments | No | Keep both, rename one |
Converging both tabs #
Once resolved, the other tab must learn about it or it will re-create the conflict on its next debounce.
// converge.ts
const channel = new BroadcastChannel('draft-sync-v1');
export function announceResolution(docId: string, winner: DraftRecord) {
channel.postMessage({ type: 'resolved', docId, vector: winner.vector, content: winner.content, from: TAB_ID });
}
channel.onmessage = (event) => {
const msg = event.data;
if (msg.from === TAB_ID || msg.type !== 'resolved') return;
// Adopt the resolved branch without re-entering the save path.
applyRemoteResolution(msg.docId, msg.content, msg.vector);
};
The from === TAB_ID guard is the echo suppression every BroadcastChannel integration needs, described in more depth in Cross-Tab State Synchronization. Without it, applying a resolution triggers a save, which broadcasts, which triggers a save in the other tab, and the two ping-pong until one of them is closed.
Verification #
- Reproduce the conflict deterministically. Open two contexts, type in both without letting either debounce flush to the other, then let both save. The second save must return
conflict, notsaved. - Assert nothing was deleted. After a conflict, the object store must contain two records for the document; count them in the test.
- Resolve as “both”. Confirm the merged content contains every word from both branches and that the sibling record is removed only after the write succeeds.
- Check convergence. After resolving in tab A, tab B must display the resolved content within a second and must not immediately save a new conflicting version — the echo guard.
- Kill a tab mid-conflict. Close tab B while the conflict dialog is open in tab A; the parked record must still be there on the next load, so no resolution path can lose data.
Frequently asked questions #
Why is a timestamp not enough to resolve draft conflicts?
Because clock order is not causal order. A later timestamp does not mean the writer had seen the earlier edit. Version vectors record what each writer had seen.
Should I auto-merge the two drafts?
Only for structurally independent fields. For prose both tabs edited, auto-merging produces text neither person wrote — keep both and let the user choose.
Can I prevent conflicts instead of resolving them?
Largely, with a leader election that makes one tab the writer. You still need conflict handling for the case where the leader tab crashed while holding the lock.
Related #
- Draft Auto-Save & Recovery Workflows — the single-tab save loop this extends
- Electing a Leader Tab with the Web Locks API — preventing most conflicts before they happen
- Resolving Offline Sync Conflicts: Last-Write-Wins vs Version Vectors — the same comparison against a server