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'));
  });
}
How version vectors detect a real conflict Two horizontal writer lanes, tab A and tab B, both starting from a shared vector. Each lane advances its own counter over three saves. The final vectors are shown side by side with a comparison box concluding diverged, because each vector is ahead in one component and behind in the other. tab A tab B shared: {A:1, B:1} {A:2} {A:3} {B:2} {B:3} A holds {A:3, B:1} B holds {A:1, B:3} diverged each is ahead in one axis a timestamp would have called this "newer" and discarded three minutes of writing

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.

From detected conflict to both tabs converged Four boxes in sequence: conflict detected on write, both branches stored with the losing branch parked as a sibling record, user chooses keep mine, keep theirs or keep both, and finally the winner is written and broadcast so the other tab adopts it without a reload. conflict detected vectors diverged both branches kept sibling record, nothing lost user chooses mine · theirs · both write + broadcast both tabs converge the losing branch is only deleted after the user has seen it and chosen echo guard: ignore broadcasts whose sender id is your own Auto-merge is safe only for structurally independent fields Two grouped lists. The safe-to-merge group contains title, tags, labels and counters with the strategy for each. The must-ask group contains prose bodies, reordered lists and binary attachments, each marked as requiring the user to choose. merge automatically title / status — take the branch that changed it tags / labels — union, then dedupe counters — sum the per-writer deltas outcome is identical whichever tab wins ask the user prose both tabs edited ordered lists both tabs reordered binary attachments auto-merging produces text nobody wrote

Verification #

  1. 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, not saved.
  2. Assert nothing was deleted. After a conflict, the object store must contain two records for the document; count them in the test.
  3. 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.
  4. 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.
  5. 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.