This page is a deep-dive under Draft Auto-Save & Recovery Workflows, which covers the full spectrum of draft persistence strategies including conflict detection and server sync.

The Exact Failure Scenario #

A user spends 40 minutes writing a 3 000-word article. The browser tab crashes β€” out-of-memory, a misbehaving extension, or a forced OS-level kill β€” at the exact moment an auto-save was in flight. On reload, the editor hydrates from the server copy, which is 12 minutes stale. Every edit since the last successful server sync is gone, with no recovery prompt offered.

The root cause is almost always one of three mistakes: (1) writing to sessionStorage synchronously on beforeunload β€” which the browser skips when the tab is killed hard; (2) queuing IndexedDB writes on the main thread so a main-thread hang kills the write too; or (3) never reading the IndexedDB store back on mount to check whether a fresher local copy exists.

The fix is a Web Worker that owns all IndexedDB I/O, a debounced flush triggered by every content mutation, and a mount-time recovery check that compares local and server versions before committing either to the editor buffer. The diagram below shows the full data-flow:

Draft recovery data-flow diagram Editor mutations flow through a debounced save queue in a Web Worker to IndexedDB. On editor mount, a recovery check reads the latest local snapshot, compares it to the server version via checksum, and loads whichever is newer into the editor buffer with cursor restoration. MAIN THREAD Editor buffer keystroke β†’ delta Recovery check on mount β€” compare checksums postMessage WEB WORKER Debounce queue 2 s timer, FLUSH override Retry logic exponential back-off Γ—3 readwrite tx INDEXEDDB drafts store keyed by documentId Snapshot record version Β· checksum Β· cursor recovery read on mount (dashed) SERVER Last confirmed save version ID for checksum compare

Zero-to-Working Code #

The snippet below is a self-contained module. It opens the database, starts the Worker, wires the debounced save, and performs the mount-time recovery check. Drop it into any editor that exposes an onChange callback and a setValue setter.

// draft-recovery.ts
import { openDB, IDBPDatabase } from 'idb';

export interface DraftSnapshot {
  documentId: string;
  version: number;                    // Monotonically increasing server version
  content: string;
  cursorPosition: { line: number; col: number };
  checksum: string;                   // SHA-256 hex of content
  savedAt: number;                    // Date.now() at write time
}

// ─── 1. Open (or upgrade) the IndexedDB store ────────────────────────────────
export async function openDraftDB(): Promise<IDBPDatabase> {
  return openDB('editor-drafts', 1, {
    upgrade(db) {
      if (!db.objectStoreNames.contains('drafts')) {
        const store = db.createObjectStore('drafts', { keyPath: 'documentId' });
        store.createIndex('savedAt', 'savedAt');  // for TTL eviction queries
      }
    },
  });
}

// ─── 2. Checksum helper (Web Crypto β€” no import needed in modern browsers) ───
export async function sha256Hex(text: string): Promise<string> {
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
  return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
}

// ─── 3. Debounced save via Web Worker ────────────────────────────────────────
//   The Worker (draft-worker.ts compiled to /workers/draft-worker.js) owns
//   all IndexedDB writes β€” keeping the main thread free for rendering.
let _worker: Worker | null = null;

function getDraftWorker(): Worker {
  if (!_worker) {
    _worker = new Worker('/workers/draft-worker.js');
    _worker.onerror = (e) => console.error('[draft-worker] fatal:', e.message);
  }
  return _worker;
}

export function enqueueSave(snapshot: DraftSnapshot): void {
  getDraftWorker().postMessage({ type: 'ENQUEUE', payload: snapshot });
}

export function flushNow(): void {
  // Call before manual save or on visibilitychange β†’ hidden
  getDraftWorker().postMessage({ type: 'FLUSH' });
}

// ─── 4. Mount-time recovery check ────────────────────────────────────────────
export async function recoverDraft(
  documentId: string,
  serverVersion: number,
  serverContent: string,
  setValue: (content: string) => void,
  setCursor: (pos: { line: number; col: number }) => void,
  getLineCount: () => number,
  getLineLength: (line: number) => number,
): Promise<boolean> {
  const db = await openDraftDB();
  const local = await db.get('drafts', documentId) as DraftSnapshot | undefined;

  if (!local || local.version <= serverVersion) return false; // server is authoritative

  // Validate the stored checksum before trusting the content
  const expected = await sha256Hex(local.content);
  if (expected !== local.checksum) {
    console.warn('[draft-recovery] checksum mismatch β€” discarding corrupt snapshot');
    await db.delete('drafts', documentId);
    return false;
  }

  setValue(local.content);

  // Clamp cursor to actual document bounds post-restore
  const safeLine = Math.min(local.cursorPosition.line, getLineCount() - 1);
  const safeCol  = Math.min(local.cursorPosition.col,  getLineLength(safeLine));
  setCursor({ line: safeLine, col: safeCol });

  return true; // caller should show "Draft recovered" banner
}
// workers/draft-worker.ts  (compiled to /workers/draft-worker.js)
import { openDB } from 'idb';
import type { DraftSnapshot } from '../draft-recovery';

let queue: DraftSnapshot[] = [];
let timer: ReturnType<typeof setTimeout> | null = null;
const DEBOUNCE_MS = 2_000;
const MAX_RETRIES = 3;

async function flush(retries = 0): Promise<void> {
  if (queue.length === 0) return;
  const batch = [...queue];
  queue = [];

  try {
    const db = await openDB('editor-drafts', 1);
    const tx  = db.transaction('drafts', 'readwrite');
    await Promise.all(batch.map(s => tx.store.put(s)));
    await tx.done;
  } catch (err) {
    if (retries < MAX_RETRIES) {
      const delay = 200 * Math.pow(2, retries);
      setTimeout(() => flush(retries + 1), delay);
    } else {
      self.postMessage({ type: 'SAVE_FAILED', error: String(err) });
    }
  }
}

self.addEventListener('message', (e: MessageEvent) => {
  if (e.data.type === 'ENQUEUE') {
    queue.push(e.data.payload);
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => flush(), DEBOUNCE_MS);
  }
  if (e.data.type === 'FLUSH') {
    if (timer) clearTimeout(timer);
    flush();
  }
});

Step-by-Step Walkthrough #

  1. openDraftDB β€” creates a single drafts object store keyed by documentId with a secondary index on savedAt. The index lets a background task evict snapshots older than 30 days without a full-store scan.

  2. sha256Hex β€” uses the browser’s native crypto.subtle.digest (no external dependency). The hash is stored alongside the content so that the recovery path can detect bit-rot or truncated writes before loading corrupt data into the editor.

  3. getDraftWorker / enqueueSave β€” instantiates the Worker lazily and forwards each DraftSnapshot as a ENQUEUE message. The Worker’s debounce timer resets on every message, so rapid keystrokes produce a single write 2 s after the last one β€” not one write per keystroke.

  4. flushNow β€” posts a FLUSH message to drain the queue immediately. Call this on visibilitychange (tab goes hidden), on freeze (Page Lifecycle API), and when the user triggers a deliberate manual save. This closes the window left open by hard kills that fire neither beforeunload nor pagehide.

  5. recoverDraft β€” reads the IndexedDB record for documentId. If the local version exceeds serverVersion, the content is likely newer than the server copy. The checksum is validated first; a mismatch triggers a delete (the snapshot is corrupt) rather than loading bad data. If the checksum passes, setValue loads the content and the cursor is clamped to safe bounds via getLineCount / getLineLength β€” necessary because the recovered document may differ in length from what the editor’s internals currently expect.

The integration point in a React editor looks like this:

// EditorPage.tsx (integration example)
import { useEffect, useRef } from 'react';
import { enqueueSave, flushNow, recoverDraft, sha256Hex } from './draft-recovery';

export function EditorPage({ documentId, serverVersion, serverContent }: Props) {
  const editorRef = useRef<EditorInstance>(null);

  // Recovery on mount
  useEffect(() => {
    recoverDraft(
      documentId,
      serverVersion,
      serverContent,
      (c) => editorRef.current?.setValue(c),
      (pos) => editorRef.current?.setCursor(pos),
      () => editorRef.current?.getLineCount() ?? 0,
      (l) => editorRef.current?.getLineLength(l) ?? 0,
    ).then((recovered) => {
      if (recovered) showRecoveryBanner();
    });
  }, [documentId]);

  // Flush before tab disappears
  useEffect(() => {
    const handler = () => { if (document.hidden) flushNow(); };
    document.addEventListener('visibilitychange', handler);
    return () => document.removeEventListener('visibilitychange', handler);
  }, []);

  const handleChange = async (content: string) => {
    const checksum = await sha256Hex(content);
    enqueueSave({
      documentId,
      version: serverVersion,   // bump this after each server ack
      content,
      cursorPosition: editorRef.current?.getCursor() ?? { line: 0, col: 0 },
      checksum,
      savedAt: Date.now(),
    });
  };

  return <Editor ref={editorRef} onChange={handleChange} />;
}

Edge Cases #

Scenario Symptom Fix
Multiple tabs open on the same document Two Workers race to write; the slower one overwrites a newer snapshot Use a BroadcastChannel so non-leader tabs post to a SharedWorker that serialises all writes
visibilitychange fires but freeze does not (older iOS Safari) In-flight debounce not flushed before tab suspend Also listen for pagehide with e.persisted === true and call flushNow()
IndexedDB quota exceeded (β‰₯80 % of origin quota) QuotaExceededError inside the Worker Check navigator.storage.estimate() inside the Worker before each write; evict snapshots older than 7 days via the savedAt index before retrying
Editor hydrates from SSR HTML before recoverDraft resolves Server content flickers in, then is replaced by local draft Defer editorRef.current?.setValue(serverContent) until after recoverDraft settles; show a skeleton until then β€” see Hydration Mismatch & State Recovery for the full deferral pattern

Verification Steps #

DevTools β€” IndexedDB inspector: Open Chrome DevTools > Application > Storage > IndexedDB > editor-drafts > drafts. After typing in the editor and waiting 2 s, you should see a record with the correct documentId, an incrementing savedAt, and a 64-character checksum.

Simulate a hard crash: In the Performance panel, use β€œKill tab” (or close the tab from the OS process list without using the tab’s close button). Reopen the editor URL. The recovery banner should appear, and the cursor should be at the last saved position.

Test assertion (Vitest + fake-indexeddb):

import 'fake-indexeddb/auto';
import { openDraftDB, recoverDraft, sha256Hex } from './draft-recovery';

test('recoverDraft loads newer local snapshot and rejects corrupt checksum', async () => {
  const db = await openDraftDB();
  const content = 'Hello, world!';
  const checksum = await sha256Hex(content);

  await db.put('drafts', {
    documentId: 'doc-1',
    version: 5,           // newer than server version 3
    content,
    cursorPosition: { line: 0, col: 5 },
    checksum,
    savedAt: Date.now(),
  });

  let restored = '';
  const recovered = await recoverDraft(
    'doc-1', 3, 'old server content',
    (c) => { restored = c; },
    () => {},
    () => 1,
    () => 100,
  );

  expect(recovered).toBe(true);
  expect(restored).toBe(content);

  // Corrupt checksum case
  await db.put('drafts', {
    documentId: 'doc-2', version: 5, content,
    cursorPosition: { line: 0, col: 0 },
    checksum: 'badhash', savedAt: Date.now(),
  });
  const corruptRecovered = await recoverDraft(
    'doc-2', 3, '', () => {}, () => {}, () => 1, () => 0,
  );
  expect(corruptRecovered).toBe(false);
});

Lighthouse: Run a mobile Lighthouse audit. The visibilitychange flush handler should not appear as a long task (> 50 ms) in the Performance timeline since the Worker absorbs the IndexedDB I/O.

Frequently Asked Questions #

Why must the IndexedDB write happen in a Web Worker rather than on the main thread? Serialising large document deltas to IndexedDB on the main thread produces long tasks that block rendering and can trigger the browser’s unresponsive-tab warning. Offloading to a Worker keeps the main thread free for keystrokes and layout, and means a main-thread crash does not interrupt an in-flight save.

What debounce interval should I use for the auto-save? 2 000 ms suits most long-form editors β€” it batches bursts of keystrokes without letting too much unsaved work accumulate. Reduce to 500 ms if the document exceeds 50 KB or if navigator.connection.effectiveType reports '4g'; increase to 5 000 ms on 'slow-2g' to reduce write pressure.

How do I handle the race between auto-save and a deliberate manual save? Route both through the same single-writer queue in the Worker. When the user triggers a manual save, post a FLUSH message that drains the queue immediately and cancels any pending debounce timer. Never allow two concurrent readwrite transactions on the same object store β€” the second will block until the first commits or aborts.