This page is part of the LocalStorage & IndexedDB Sync Strategies guide, which sits under the broader Session State Persistence & Hydration Fallbacks coverage of client-side durability patterns.

The exact failure this page solves #

A user spends twenty minutes filling a multi-step checkout form. The OS kills the tab due to memory pressure. On reload, React re-mounts with empty useState initial values, and every input is blank. The fix is a useIndexedDBSync hook that intercepts every state update, debounces writes into an IndexedDB transaction, and rehydrates from the last committed snapshot on mount β€” so the user sees their progress restored automatically.

This is a narrower problem than general draft auto-save recovery workflows: the concern here is crash durability of arbitrary React state trees, not just rich-text editors.


Zero-to-working implementation #

The snippet below is the complete hook. It handles DB init, enqueue-and-debounce, atomic write, and rehydration. Copy it as-is; the step-by-step explanation follows.

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

const DB_NAME  = 'app_state_v1';
const STORE    = 'session';
const KEY      = 'current';
const DEBOUNCE = 300; // ms β€” tune per write frequency

// Handles BigInt, Date, Map, Set which JSON.stringify drops silently
function serialize(value: unknown): string {
  return JSON.stringify(value, (_k, v) => {
    if (typeof v === 'bigint')   return { __t: 'BigInt', v: v.toString() };
    if (v instanceof Date)       return { __t: 'Date',   v: v.toISOString() };
    if (v instanceof Map)        return { __t: 'Map',    v: [...v.entries()] };
    if (v instanceof Set)        return { __t: 'Set',    v: [...v] };
    return v;
  });
}

function deserialize(raw: string): unknown {
  return JSON.parse(raw, (_k, v) => {
    if (v && typeof v === 'object' && '__t' in v) {
      if (v.__t === 'BigInt') return BigInt(v.v);
      if (v.__t === 'Date')   return new Date(v.v);
      if (v.__t === 'Map')    return new Map(v.v);
      if (v.__t === 'Set')    return new Set(v.v);
    }
    return v;
  });
}

function openDB(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, 1);
    req.onupgradeneeded = e => {
      const db = (e.target as IDBOpenDBRequest).result;
      if (!db.objectStoreNames.contains(STORE)) {
        db.createObjectStore(STORE); // key-path-less store; we supply the key
      }
    };
    req.onsuccess = e => resolve((e.target as IDBOpenDBRequest).result);
    req.onerror   = e => reject((e.target as IDBOpenDBRequest).error);
  });
}

function idbPut(db: IDBDatabase, value: unknown): Promise<void> {
  return new Promise((resolve, reject) => {
    const tx    = db.transaction(STORE, 'readwrite');
    const store = tx.objectStore(STORE);
    store.put(serialize(value), KEY);
    tx.oncomplete = () => resolve();
    tx.onerror    = () => reject(tx.error);
    tx.onabort    = () => reject(new Error('IDB transaction aborted'));
  });
}

function idbGet(db: IDBDatabase): Promise<unknown> {
  return new Promise((resolve, reject) => {
    const tx  = db.transaction(STORE, 'readonly');
    const req = tx.objectStore(STORE).get(KEY);
    req.onsuccess = () =>
      resolve(req.result != null ? deserialize(req.result as string) : undefined);
    req.onerror = () => reject(req.error);
  });
}

export function useIndexedDBSync<T>(
  initial: T,
  shouldPersist: (s: T) => boolean = () => true,
) {
  const [state, setStateRaw] = useState<T>(initial);
  const dbRef    = useRef<IDBDatabase | null>(null);
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const pending  = useRef<T | null>(null);

  // Step 1: open DB and rehydrate
  useEffect(() => {
    let cancelled = false;
    openDB().then(async db => {
      if (cancelled) return;
      dbRef.current = db;
      const saved = await idbGet(db);
      if (!cancelled && saved !== undefined) setStateRaw(saved as T);
    });
    return () => { cancelled = true; };
  }, []);

  const setState = useCallback((next: T | ((prev: T) => T)) => {
    setStateRaw(prev => {
      const resolved = typeof next === 'function'
        ? (next as (p: T) => T)(prev)
        : next;
      if (shouldPersist(resolved)) {
        pending.current = resolved;
        if (timerRef.current) clearTimeout(timerRef.current);
        timerRef.current = setTimeout(async () => {
          const db = dbRef.current;
          if (!db || pending.current === null) return;
          const snapshot = pending.current;
          pending.current = null;
          try {
            await idbPut(db, snapshot);
          } catch (err) {
            // rollback: restore last good snapshot
            const last = await idbGet(db);
            if (last !== undefined) setStateRaw(last as T);
            console.error('[useIndexedDBSync] write failed; rolled back:', err);
          }
        }, DEBOUNCE);
      }
      return resolved;
    });
  }, [shouldPersist]);

  return { state, setState };
}

Step-by-step explanation #

  1. openDB() β€” initialise and upgrade. The onupgradeneeded callback runs only when the database version is new; it creates the object store exactly once. onsuccess resolves with the live IDBDatabase handle stored in dbRef.

  2. Rehydrate on mount. Inside the useEffect, idbGet() opens a readonly transaction and fetches the last committed value. If the record exists it calls setStateRaw (bypassing the sync path, because the data is already persisted). The cancelled flag prevents a state update after unmount.

  3. setState wrapper β€” enqueue and debounce. Every call resolves the next state value synchronously (matching React’s own setState contract), then stores it in pending.current. A 300 ms debounce timer coalesces rapid updates β€” e.g. keystroke-by-keystroke form edits β€” into a single write per burst.

  4. idbPut() β€” atomic write. Opening a readwrite transaction and calling store.put() is not enough: the promise resolves only when tx.oncomplete fires, which the browser engine guarantees happens only after the write is flushed to disk. tx.onerror and tx.onabort both reject so the catch block in setState can trigger rollback.

  5. Rollback on failure. When idbPut rejects, the hook fetches the most recent committed snapshot via idbGet() and calls setStateRaw with it, returning React’s visible state to the last durable checkpoint. This prevents the UI from displaying state that was never safely stored.

  6. shouldPersist predicate. Passing s => s.isDirty (or similar) lets callers exclude derived, ephemeral, or binary state β€” keeping write volume and storage consumption proportional to actual user input.


Edge cases #

Scenario Symptom Mitigation
Circular reference in Zustand / Redux slice DataCloneError at JSON.stringify Enforce immutable state slices; add a WeakMap-based cycle detector in serialize() before calling JSON.stringify
Safari aggressive storage eviction IndexedDB silently cleared on low-disk Always check req.result != null in idbGet; treat undefined as β€œno snapshot” and keep initial as the fallback
Two tabs writing concurrently Last-write wins, mid-flight tab sees stale snapshot Coordinate with navigator.locks.request('idb-sync', ...) before each idbPut, or append a monotonic version field and reject stale writes
QuotaExceededError on large state trees Write fails silently if not caught Catch QuotaExceededError separately from tx.onerror; run LRU eviction (see below) then retry once before surfacing a user warning

Quota-aware LRU eviction #

When navigator.storage.estimate() reports usage above 90%, delete oldest snapshots before retrying a write:

async function evictIfNeeded(db: IDBDatabase): Promise<void> {
  const est = await navigator.storage?.estimate();
  if (!est?.usage || !est?.quota) return;
  if (est.usage / est.quota < 0.9) return;

  // Collect all keys, keep the 50 most recent, delete the rest
  const keys = await new Promise<IDBValidKey[]>((resolve, reject) => {
    const tx  = db.transaction(STORE, 'readonly');
    const req = tx.objectStore(STORE).getAllKeys();
    req.onsuccess = () => resolve(req.result);
    req.onerror   = () => reject(req.error);
  });

  if (keys.length <= 50) return;

  const toDelete = keys.slice(0, keys.length - 50);
  await new Promise<void>((resolve, reject) => {
    const tx    = db.transaction(STORE, 'readwrite');
    const store = tx.objectStore(STORE);
    toDelete.forEach(k => store.delete(k));
    tx.oncomplete = () => resolve();
    tx.onerror    = () => reject(tx.error);
  });
}

Sync flow diagram #

The diagram below shows one full write cycle: React state mutation β†’ debounce β†’ IDB transaction β†’ commit or rollback.

React-to-IndexedDB sync flow Flowchart showing a React setState call entering a 300 ms debounce queue, then an IDB readwrite transaction. On oncomplete the new snapshot is durable; on onerror or onabort the hook fetches the last committed snapshot and calls setStateRaw to restore it. setState() React hook 300 ms debounce coalesces bursts IDB readwrite transaction fetch last snapshot β†’ setStateRaw() oncomplete durable βœ“ onerror / onabort β†’ rollback step 3 step 4 step 5

Verification steps #

After wiring up useIndexedDBSync, confirm correctness with these checks:

  1. DevTools Application tab β†’ IndexedDB. Open app_state_v1 > session and confirm the current key appears after typing in a form field. The value should update approximately once per 300 ms burst (not on every keystroke).

  2. Crash simulation. In DevTools Console, run window.stop() or close the tab forcibly. Reopen it. The form fields should repopulate from IndexedDB within the first render cycle β€” no flash of empty inputs.

  3. Abort path. Override IDBTransaction.prototype.commit to throw immediately, trigger a state update, and assert that the React tree shows the previous state value (rollback succeeded), not the failed write.

  4. Quota edge case. Use DevTools β†’ Application β†’ Storage to set a quota override of 5 MB. Fill the store with dummy data until eviction fires, then confirm the hook still writes the new snapshot without throwing an unhandled QuotaExceededError.

  5. TypeScript build. The hook is generic over T; run tsc --noEmit to confirm the shouldPersist predicate infers the correct state type without casting.


Frequently asked questions #

Does IndexedDB guarantee atomicity if the browser crashes mid-write?

Yes. The IDB specification requires that any transaction not yet committed is rolled back on process termination. On next startup, store.get(KEY) returns the last fully committed record β€” you will never read a half-written payload.

How do I prevent the sync loop from blocking React renders?

Keep all IDB work asynchronous: open transactions after the render commits, serialise large payloads inside requestIdleCallback or a Web Worker, and never await an IDB call inside a render function. The debounce timer in setState already ensures writes fire off-render; for payloads above ~5 MB consider moving serialize() to a Worker via postMessage.

What if two browser tabs are open and both write at the same time?

Wrap each idbPut call with navigator.locks.request('idb-sync', { mode: 'exclusive' }, ...). Only one tab holds the lock at a time; the other queues behind it. For conflict resolution on rehydration, compare a version counter stored alongside the payload and always take the higher version as the source of truth.