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:
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 #
-
openDraftDBβ creates a singledraftsobject store keyed bydocumentIdwith a secondary index onsavedAt. The index lets a background task evict snapshots older than 30 days without a full-store scan. -
sha256Hexβ uses the browserβs nativecrypto.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. -
getDraftWorker/enqueueSaveβ instantiates the Worker lazily and forwards eachDraftSnapshotas aENQUEUEmessage. 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. -
flushNowβ posts aFLUSHmessage to drain the queue immediately. Call this onvisibilitychange(tab goes hidden), onfreeze(Page Lifecycle API), and when the user triggers a deliberate manual save. This closes the window left open by hard kills that fire neitherbeforeunloadnorpagehide. -
recoverDraftβ reads the IndexedDB record fordocumentId. If the localversionexceedsserverVersion, 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,setValueloads the content and the cursor is clamped to safe bounds viagetLineCount/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.
Related #
- Draft Auto-Save & Recovery Workflows β parent page covering the full auto-save architecture including server sync and conflict resolution
- LocalStorage & IndexedDB Sync Strategies β choosing the right storage primitive and sync pattern for crash resilience
- Hydration Mismatch & State Recovery β deferring editor hydration to prevent server-rendered HTML from overwriting a recovered draft