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 #
-
openDB()β initialise and upgrade. Theonupgradeneededcallback runs only when the database version is new; it creates the object store exactly once.onsuccessresolves with the liveIDBDatabasehandle stored indbRef. -
Rehydrate on mount. Inside the
useEffect,idbGet()opens areadonlytransaction and fetches the last committed value. If the record exists it callssetStateRaw(bypassing the sync path, because the data is already persisted). Thecancelledflag prevents a state update after unmount. -
setStatewrapper β enqueue and debounce. Every call resolves the next state value synchronously (matching Reactβs ownsetStatecontract), then stores it inpending.current. A 300 ms debounce timer coalesces rapid updates β e.g. keystroke-by-keystroke form edits β into a single write per burst. -
idbPut()β atomic write. Opening areadwritetransaction and callingstore.put()is not enough: the promise resolves only whentx.oncompletefires, which the browser engine guarantees happens only after the write is flushed to disk.tx.onerrorandtx.onabortboth reject so thecatchblock insetStatecan trigger rollback. -
Rollback on failure. When
idbPutrejects, the hook fetches the most recent committed snapshot viaidbGet()and callssetStateRawwith it, returning Reactβs visible state to the last durable checkpoint. This prevents the UI from displaying state that was never safely stored. -
shouldPersistpredicate. Passings => 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.
Verification steps #
After wiring up useIndexedDBSync, confirm correctness with these checks:
-
DevTools Application tab β IndexedDB. Open
app_state_v1 > sessionand confirm thecurrentkey appears after typing in a form field. The value should update approximately once per 300 ms burst (not on every keystroke). -
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. -
Abort path. Override
IDBTransaction.prototype.committo throw immediately, trigger a state update, and assert that the React tree shows the previous state value (rollback succeeded), not the failed write. -
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. -
TypeScript build. The hook is generic over
T; runtsc --noEmitto confirm theshouldPersistpredicate 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.
Related #
- LocalStorage & IndexedDB Sync Strategies β parent guide covering storage tier selection, quota trade-offs, and cross-origin constraints
- Draft Auto-Save Recovery Workflows β IndexedDB-backed auto-save patterns for rich-text and long-form editors
- Hydration Mismatch State Recovery β resolving server/client state divergence after rehydrating from IndexedDB snapshots
- State Reset & Cleanup Protocols β clearing stale IndexedDB entries when an error boundary resets the component tree