This pattern sits within Session State Persistence & Hydration Fallbacks, which establishes the state contracts and hydration guarantees that draft recovery depends on.

The failure mode #

A user spends 40 minutes filling a complex form. The mobile browser is backgrounded, the OS kills the tab to reclaim memory, and on return the user sees a blank form. From the browser’s perspective no crash occurred β€” the page simply reloaded. Without a deliberate draft pipeline the work is gone. The failure mode is not an exception; it is a silent data drop that produces no stack trace and no error event. Every form and editor that touches user-generated content must treat persistence as a first-class concern, not an afterthought.

Prerequisites #


Save-pipeline architecture #

Draft auto-save pipeline Input events are debounced, written to in-memory state, then asynchronously persisted to IndexedDB. On reconnect a Background Sync queue flushes pending drafts to the server. User input keydown / change Debounce / flush visibilitychange In-memory (volatile) IndexedDB (durable) BG Sync queue (offline buffer) API server POST /drafts tier 1 tier 2 offline on reconnect

Core implementation #

The useDraftSync hook decouples UI events from persistence. It debounces writes, guards against concurrent in-flight saves with isSaving, and forces an immediate flush on visibilitychange so backgrounded tabs do not lose pending state.

// hooks/useDraftSync.ts
import { useState, useRef, useCallback, useEffect } from 'react';

export interface DraftPayload<T> {
  data: T;
  version: number;
  timestamp: number;
}

function createDebounce<T extends (...args: never[]) => void>(
  fn: T,
  delayMs: number,
): (...args: Parameters<T>) => void {
  let timerId: ReturnType<typeof setTimeout> | null = null;
  let lastArgs: Parameters<T> | null = null;

  return (...args: Parameters<T>) => {
    lastArgs = args;
    if (timerId) clearTimeout(timerId);
    timerId = setTimeout(() => {
      timerId = null;
      if (lastArgs) fn(...lastArgs);
    }, delayMs);
  };
}

export function useDraftSync<T>(
  initialState: T,
  saveDelayMs = 2500,
  onPersist: (payload: DraftPayload<T>) => Promise<void>,
) {
  const [draft, setDraft] = useState<DraftPayload<T>>({
    data: initialState,
    version: 0,
    timestamp: Date.now(),
  });

  // Refs avoid stale closures inside the debounced callback
  const isSaving = useRef(false);
  const pendingRef = useRef<DraftPayload<T> | null>(null);

  const debouncedPersist = useCallback(
    createDebounce(async (state: DraftPayload<T>) => {
      if (isSaving.current) {
        pendingRef.current = state; // will re-flush after current save completes
        return;
      }
      isSaving.current = true;
      try {
        await onPersist(state);
        // If a newer payload arrived while we were saving, flush it now
        if (pendingRef.current && pendingRef.current.version > state.version) {
          await onPersist(pendingRef.current);
        }
      } finally {
        isSaving.current = false;
        pendingRef.current = null;
      }
    }, saveDelayMs),
    [onPersist, saveDelayMs],
  );

  const updateDraft = useCallback(
    (nextData: T) => {
      const next: DraftPayload<T> = {
        data: nextData,
        version: draft.version + 1,
        timestamp: Date.now(),
      };
      setDraft(next);
      pendingRef.current = next;
      debouncedPersist(next);
    },
    [draft.version, debouncedPersist],
  );

  // Immediate flush when the tab loses visibility (backgrounded, closed)
  useEffect(() => {
    const handleVisibility = () => {
      if (document.visibilityState === 'hidden' && pendingRef.current) {
        // Fire-and-forget β€” browser keeps the fetch alive for a short window
        onPersist(pendingRef.current).catch(() => undefined);
      }
    };
    document.addEventListener('visibilitychange', handleVisibility);
    return () => document.removeEventListener('visibilitychange', handleVisibility);
  }, [onPersist]);

  return { draft, updateDraft, isSaving: isSaving.current };
}

Architecture note #

The hook sits between the React render cycle and the persistence layer without blocking either. Debounce runs on the main thread but the onPersist callback β€” wired to the DraftStorageManager in the next section β€” hands off to an async IndexedDB transaction before returning. The visibilitychange flush bypasses the debounce timer entirely because the Page Visibility API fires synchronously in the same microtask queue as beforeunload, giving a narrow but reliable window for a final write. On mobile browsers where beforeunload is unreliable, visibilitychange is the only hook that fires consistently before the OS reclaims the process.

Persistence layer: tiered storage #

Apply LocalStorage & IndexedDB Sync Strategies as the canonical guide for cross-tab broadcast and conflict resolution. The DraftStorageManager below handles the IndexedDB tier with quota monitoring and BroadcastChannel coordination.

// services/DraftStorageManager.ts
export class DraftStorageManager {
  private readonly dbName = 'drafts_db';
  private readonly storeName = 'drafts';
  private readonly quotaThreshold = 0.85;
  private channel: BroadcastChannel;

  constructor() {
    this.channel = new BroadcastChannel('draft_sync');
    this.channel.onmessage = (e) => this.onCrossTabUpdate(e.data);
  }

  async saveDraft(key: string, data: unknown): Promise<void> {
    const db = await this.openDB();
    const version = Date.now();
    await new Promise<void>((resolve, reject) => {
      const tx = db.transaction(this.storeName, 'readwrite');
      const store = tx.objectStore(this.storeName);
      const req = store.put({ id: key, data, version });
      req.onerror = () => reject(req.error);
      tx.oncomplete = () => resolve();
    });
    // Notify sibling tabs so they can refresh stale UI without polling
    this.channel.postMessage({ type: 'DRAFT_SAVED', key, version });
  }

  async getDraft(key: string): Promise<unknown | null> {
    const db = await this.openDB();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(this.storeName, 'readonly');
      const req = tx.objectStore(this.storeName).get(key);
      req.onsuccess = () => resolve(req.result?.data ?? null);
      req.onerror = () => reject(req.error);
    });
  }

  /** Returns true when storage utilisation exceeds the warning threshold. */
  async isNearQuota(): Promise<boolean> {
    if (!navigator.storage?.estimate) return false;
    const { usage = 0, quota = 1 } = await navigator.storage.estimate();
    return usage / quota >= this.quotaThreshold;
  }

  private onCrossTabUpdate(payload: { type: string; key: string; version: number }) {
    if (payload.type === 'DRAFT_SAVED') {
      // Dispatch a DOM event so editor components can decide whether to merge
      window.dispatchEvent(
        new CustomEvent('draft:external-update', { detail: payload }),
      );
    }
  }

  private openDB(): Promise<IDBDatabase> {
    return new Promise((resolve, reject) => {
      const req = indexedDB.open(this.dbName, 1);
      req.onupgradeneeded = () => {
        if (!req.result.objectStoreNames.contains(this.storeName)) {
          req.result.createObjectStore(this.storeName, { keyPath: 'id' });
        }
      };
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error);
    });
  }
}

Edge cases and failure modes #

The table below maps the most common runtime failure modes to concrete mitigations.

Failure mode Symptom Mitigation
Rapid tab switching during in-flight save Race between tabs overwriting the same key tabId lock in IndexedDB row; newest version wins via BroadcastChannel
Private browsing (Safari / Firefox) IndexedDB silently unavailable or wiped on session end Feature-detect, fall back to in-memory cache, warn user
OS-level storage eviction IndexedDB purged under disk pressure without error navigator.storage.estimate() + proactive export prompt at 85 %
Structured clone failure (Map, class instance) DataCloneError on store.put() Custom serializer: convert to plain objects before storage
Third-party iframe sandbox localStorage / IndexedDB access denied postMessage relay to parent frame with origin validation
Synchronous localStorage write in hot path Main thread jank, quota QuotaExceededError Move all hot-path writes to async IndexedDB; never call setItem in input handlers

Network resilience and the reconnect queue #

Coordinate with Cache Warming & Pre-Fetching on Reconnect to hydrate editor state before initiating background sync. The Service Worker below intercepts draft POST requests, queues them when offline, and registers a Background Sync tag for deferred delivery.

// sw/draft-interceptor.js
const DRAFT_QUEUE_KEY = 'draft-offline-queue';

self.addEventListener('fetch', (event) => {
  if (!event.request.url.includes('/api/drafts') || event.request.method !== 'POST') return;

  event.respondWith(
    fetch(event.request.clone()).catch(async () => {
      // Store the serialised body in Cache Storage for the sync handler
      const body = await event.request.clone().json();
      const cache = await caches.open(DRAFT_QUEUE_KEY);
      await cache.put(
        new Request(`/offline-queue/${body.key ?? Date.now()}`),
        new Response(JSON.stringify(body)),
      );
      await self.registration.sync.register('draft-sync');
      return new Response(JSON.stringify({ status: 'queued' }), {
        headers: { 'Content-Type': 'application/json' },
      });
    }),
  );
});

self.addEventListener('sync', (event) => {
  if (event.tag === 'draft-sync') {
    event.waitUntil(flushOfflineQueue());
  }
});

async function flushOfflineQueue() {
  const cache = await caches.open(DRAFT_QUEUE_KEY);
  const keys = await cache.keys();
  await Promise.all(
    keys.map(async (req) => {
      const resp = await cache.match(req);
      const body = await resp.json();
      const result = await fetch('/api/drafts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      });
      // Only delete from queue on definitive success or client error (non-retryable)
      if (result.ok || result.status < 500) {
        await cache.delete(req);
      }
    }),
  );
}

Use generateIdempotencyKey() (a crypto.getRandomValues UUID) on each draft payload before it enters the queue. The server deduplicates on that key, so Background Sync retries never create duplicate records.

Telemetry and save-state machine #

Instrument the save lifecycle to surface failures without blocking the write path. The SaveStateMachine keeps the UI indicator consistent with actual persistence state.

// state-machines/saveIndicator.ts
export type SaveState = 'idle' | 'saving' | 'saved' | 'error' | 'offline';

export function createSaveStateMachine(initial: SaveState = 'idle') {
  let state = initial;
  const listeners = new Set<(s: SaveState) => void>();

  return {
    get state() { return state; },
    transition(next: SaveState) {
      state = next;
      listeners.forEach((fn) => fn(state));
    },
    subscribe(fn: (s: SaveState) => void) {
      listeners.add(fn);
      return () => listeners.delete(fn);
    },
  };
}

// components/EditorErrorBoundary.tsx
import { Component, ErrorInfo, ReactNode } from 'react';

interface Props { children: ReactNode; fallback?: ReactNode; }
interface State { hasError: boolean; error: Error | null; }

export class EditorErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false, error: null };

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    const payload = {
      event: 'draft_recovery_error',
      severity: 'critical',
      stack: error.stack?.split('\n').slice(0, 3).join('\n'),
      componentStack: info.componentStack,
      timestamp: Date.now(),
      connection: (navigator as { connection?: { effectiveType?: string } })
        .connection?.effectiveType ?? 'unknown',
      // Never include raw draft content in telemetry
      sanitized: true,
    };
    // sendBeacon survives page unload; do not await
    navigator.sendBeacon('/api/telemetry', JSON.stringify(payload));
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback ?? (
        <div role="alert" className="draft-error-fallback">
          <p>Draft recovery failed. Your content is preserved locally.</p>
          <button onClick={() => window.location.reload()}>Restore session</button>
        </div>
      );
    }
    return this.props.children;
  }
}

Key pitfalls:

  • Ad blockers stripping telemetry: Use a first-party /api/telemetry endpoint β€” third-party SDKs are routinely blocked, leaving blind spots in save failure rates.
  • Heavy serialisation on low-end CPUs: Move draft processing to a Web Worker or requestIdleCallback to hold 60 fps during continuous typing.
  • Telemetry failures halting the save pipeline: Always fire-and-forget (sendBeacon, Promise.resolve().then()). If the telemetry call throws, the save must still complete.
  • Network-dependent status toasts causing perceived lag: Drive the save-status indicator from the local state machine; synchronise it with the server only when connectivity is stable.

Testing and CI/CD validation #

// tests/draftSync.test.ts
import { renderHook, act } from '@testing-library/react';
import { useDraftSync } from '../hooks/useDraftSync';

describe('useDraftSync', () => {
  it('calls onPersist after the debounce interval', async () => {
    vi.useFakeTimers();
    const onPersist = vi.fn().mockResolvedValue(undefined);
    const { result } = renderHook(() =>
      useDraftSync({ text: '' }, 500, onPersist),
    );

    act(() => result.current.updateDraft({ text: 'hello' }));
    expect(onPersist).not.toHaveBeenCalled();

    await act(async () => { vi.advanceTimersByTime(500); });
    expect(onPersist).toHaveBeenCalledOnce();
    expect(onPersist.mock.calls[0][0].data).toEqual({ text: 'hello' });
    vi.useRealTimers();
  });

  it('flushes immediately on visibilitychange to hidden', async () => {
    const onPersist = vi.fn().mockResolvedValue(undefined);
    const { result } = renderHook(() =>
      useDraftSync({ text: '' }, 5000, onPersist),
    );

    act(() => result.current.updateDraft({ text: 'unsaved' }));
    // Simulate tab backgrounding before debounce fires
    Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
    document.dispatchEvent(new Event('visibilitychange'));

    await act(async () => { /* allow microtasks to flush */ });
    expect(onPersist).toHaveBeenCalledOnce();
  });
});

For CI, inject a beforeunload crash trigger via Playwright and assert that a subsequent page load restores draft content from IndexedDB within 100 ms. Use page.evaluate(() => indexedDB.deleteDatabase('drafts_db')) in teardown to reset state between runs.

Deep-dives under this topic #

The domain-specific extension of these patterns β€” full state machine topology, crash telemetry correlation, and rollback procedures with command-pattern undo/redo β€” is covered in Implementing Draft Recovery for Long-Form Editors. That page also covers audit trail architecture and GDPR-compliant log rotation for enterprise editors.

Frequently Asked Questions #

How do we handle draft version conflicts when multiple tabs are open? Implement a BroadcastChannel (or SharedWorker for richer leader-election) to coordinate a single source of truth. When a tab receives a DRAFT_SAVED notification with a version newer than its local state, it pauses auto-save, fetches the latest snapshot, and presents a diff to the user before merging. Use timestamp-based last-write-wins as the default strategy; escalate to a user-prompt diff UI when the timestamps are within the debounce window.

What is the recommended debounce interval for auto-save? Start at 2–3 seconds for text inputs and adjust based on observed input velocity. Reduce to 500 ms when the user pauses typing for more than one debounce cycle; increase to 5 s during continuous high-velocity input to reduce storage thrashing. Feed navigator.connection.effectiveType into the calculation β€” on 2g or slow-2g connections, lengthen the interval and skip the network commit until the Background Sync event fires.

Is the Background Sync API suitable for high-frequency draft saves? No. Background Sync is a coarse delivery mechanism β€” the browser decides when to fire the sync event and may batch multiple registrations of the same tag. Use it only for the final offline flush. For in-session saves, write directly to IndexedDB on the debounced interval; that path is always available regardless of network state.

How should QA validate recovery after a hard browser crash? Simulate process termination via chrome://inspect (Android) or about:debugging (Firefox). Verify IndexedDB persistence in the Application β†’ Storage panel. Assert that UI restoration matches the last committed snapshot within 100 ms of reload by reading window.performance.getEntriesByType('navigation')[0].loadEventEnd and comparing it against the draft hydration timestamp written to sessionStorage during the restore sequence. Automate this with a Playwright test that kills the renderer process (page.close({ runBeforeUnload: false })) and relaunches.