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 decision this page resolves #
Every crash-recovery implementation starts with the same fork in the road: does the recovery payload go into localStorage or IndexedDB? Teams that pick wrong discover it under pressure — either a checkout form silently truncates at the 5 MB QuotaExceededError boundary, or a debounced autosave stutters typing because a synchronous write is blocking the main thread on every keystroke. This guide gives you the axes that actually matter for crash recovery — not general storage advice — and a concrete recommendation you can apply without re-deriving it from first principles each time.
The short version: localStorage is a synchronous, main-thread-blocking, string-only key-value store with a hard ceiling around 5 MB and no transactional guarantees. IndexedDB is an asynchronous, transactional, structured-clone database with orders of magnitude more headroom and atomic commit/rollback semantics. For crash recovery specifically — where you need the last state to survive an ungraceful termination without corruption — that difference is not cosmetic; it determines whether your recovery path is reliable at all.
Comparing the two storage tiers #
| Axis | localStorage | IndexedDB |
|---|---|---|
| Execution model | Synchronous, blocks the main thread on every read/write | Asynchronous; transactions run off the render path |
| Data shape | Strings only — Dates, Maps, Sets, Blobs need manual (de)serialization | Structured clone — stores Dates, Maps, Sets, Blobs, and nested objects natively |
| Capacity | ~5 MB per origin, enforced strictly and inconsistently across browsers | Hundreds of MB to low GB, subject to navigator.storage.estimate() |
| Atomicity on crash | None — a write interrupted mid-flight can leave a partial or truncated value | Transactional — a write only becomes visible on oncomplete; interrupted transactions roll back entirely |
| Write frequency tolerance | Poor beyond occasional writes; every call is synchronous I/O on the main thread | Good — batch, debounce, or stream high-frequency writes without blocking rendering |
| SSR / first paint | Available synchronously before hydration, if accessed pre-render | Requires an async round trip; cannot be read synchronously during SSR or before first paint |
The pattern in that table is consistent: localStorage wins on immediacy (synchronous, available pre-hydration) and loses on everything related to durability at scale (size, shape, atomicity, frequency). IndexedDB is the inverse. Crash recovery is fundamentally a durability problem, which is why the recommendation below leans heavily toward IndexedDB — with localStorage kept only for the narrow slice of the problem it is actually good at.
Recommendation #
Default to IndexedDB for anything beyond a few kilobytes, anything written more than occasionally, or anything where a half-committed write would corrupt the recovery path. That covers the majority of crash-recovery use cases — form state, editor content, cart contents, multi-step wizards. The pattern used for syncing React state to IndexedDB is the reference implementation: debounced writes, an oncomplete-gated promise, and rollback to the last committed snapshot on onerror/onabort.
Reserve localStorage for two narrow roles: a tiny boolean or short string flag (for example, “a recovery snapshot exists”) that must be readable synchronously before or during hydration, and — optionally — a small hot-path snapshot of the most recent state for the interval between paint and the first IndexedDB read resolving. Never store the full recovery payload in localStorage if it can grow past a few kilobytes or change more than a handful of times per session; both the size ceiling and the blocking write model will eventually cause a visible failure.
Dual-write pattern: synchronous flag + async payload #
When you need both — an instant synchronous signal and a durable large payload — write the flag first and let the full payload land asynchronously. The two writes are decoupled: the flag never waits on the transaction.
// dualWrite.ts
const FLAG_KEY = 'recovery_pending';
const DB_NAME = 'crash_recovery_v1';
const STORE = 'snapshots';
const RECORD_KEY = 'latest';
// Step 1: synchronous, main-thread flag — safe because it is tiny and rare
function markRecoveryPending(version: number): void {
try {
localStorage.setItem(FLAG_KEY, String(version));
} catch {
// QuotaExceededError here means the origin's 5 MB is already exhausted
// by other keys; the async IndexedDB write below is unaffected.
}
}
function clearRecoveryFlag(): void {
localStorage.removeItem(FLAG_KEY);
}
// Step 2: asynchronous, transactional payload write
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);
};
req.onsuccess = e => resolve((e.target as IDBOpenDBRequest).result);
req.onerror = e => reject((e.target as IDBOpenDBRequest).error);
});
}
export async function persistSnapshot(
db: IDBDatabase,
payload: unknown,
version: number,
): Promise<void> {
// Flag goes first and synchronously — it survives even if the tab
// dies before the transaction below resolves.
markRecoveryPending(version);
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put({ payload, version }, RECORD_KEY);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(new Error('snapshot transaction aborted'));
});
// Only clear the flag once the durable write is confirmed committed.
clearRecoveryFlag();
}
On startup, a pending flag with no matching committed version tells you the tab died mid-write — recover from the last committed IndexedDB record (there is nothing else to recover, since the interrupted write never committed) and clear the stale flag.
How to choose: numbered steps #
- Measure the payload. Serialize a representative sample of the state and check its byte size. If it regularly exceeds a few kilobytes,
localStorageis disqualified outright by the 5 MB ceiling and the cost of synchronous serialization on every write. - Count the writes per minute. Keystroke-level or interval-driven updates on
localStorageblock the main thread on each call; anything more frequent than an occasional save needsIndexedDB’s asynchronous transactions. - Decide how much atomicity you need. If a half-written value would leave the recovery path corrupted rather than merely stale, you need
IndexedDB’s commit/rollback guarantee —localStorageoffers no equivalent. - Check the data shape. Dates, Maps, Sets, Blobs, or deeply nested objects either need manual serialization for
localStorageor get structured-clone support for free inIndexedDB. - Account for SSR and first paint. If a value must be read synchronously before or during hydration, only
localStoragecan serve that read; plan forIndexedDBto resolve slightly later. - Implement the dual-write if both constraints apply. Use the pattern above: flag first and synchronous, payload second and transactional, flag cleared only after the transaction commits.
Edge cases #
| Scenario | Risk | Mitigation |
|---|---|---|
| Payload grows past a few KB after launch | Team ships on localStorage, then hits QuotaExceededError in production as features add fields |
Set a byte-size budget in code review; migrate to IndexedDB before the ceiling is reached, not after |
| Safari private browsing or storage pressure | Both APIs can silently evict data with no write error | Treat every read as possibly empty; never assume a snapshot exists, fall back to component defaults |
| Flag says “pending” but IndexedDB has no matching version | Tab crashed between the flag write and the transaction commit | Recover from the highest committed version in IndexedDB and clear the stale flag; do not trust the flag’s own value as data |
High-frequency writes on localStorage “because it’s simpler” |
Main-thread jank during rapid typing or drag interactions | Move any write occurring more than a few times per second to a debounced IndexedDB transaction, even if the payload is small |
Verification steps #
- Confirm the flag is synchronous. In DevTools Console, call your
markRecoveryPendingfunction and immediately readlocalStorage.getItem('recovery_pending')on the next line — it must already reflect the new value with no awaiting. - Confirm the payload write is non-blocking. Trigger a large
persistSnapshotcall and use the Performance panel to verify no long task appears on the main thread during the transaction; the blocking work should only be thelocalStorage.setItemcall for the flag. - Simulate a crash between flag and commit. Call
markRecoveryPending(), then throw before the transaction starts (or kill the tab). On reload, confirm your recovery logic detects a pending flag with no matching committed version and falls back to the last good IndexedDB record. - Check quota behavior for each tier. Use DevTools → Application → Storage to override the quota to a small value; confirm
localStoragethrowsQuotaExceededErrordistinctly from anyIndexedDBerror, and that your dual-write code handles each independently. - Measure payload size in CI. Add an assertion that serializes representative state and fails the build if it exceeds your chosen
localStoragebudget (for example, 2 KB), catching drift before it reaches production.
Frequently asked questions #
Can I just use localStorage for everything and skip IndexedDB entirely?
Only if the state is genuinely tiny (a few KB) and writes are infrequent. Because localStorage writes are synchronous and block the main thread, using it for large or frequently updated state degrades interaction responsiveness and risks silent truncation near the 5 MB quota.
Is IndexedDB slower than localStorage for small, simple flags?
For a single boolean or short string read before first paint, localStorage is faster because it is synchronous with no transaction overhead. For anything larger or written repeatedly, IndexedDB’s asynchronous transactions outperform localStorage because they never block rendering.
What happens if the tab crashes between the localStorage write and the IndexedDB write in a dual-write setup?
The localStorage flag is written first and synchronously, so it will always be present even if the IndexedDB write never starts. On restart, check the flag: if it is set but the IndexedDB payload is missing or stale, treat the flag as a hint to recover from the last-known-good IndexedDB snapshot rather than trusting the flag’s own value as full state.
Related #
- LocalStorage & IndexedDB Sync Strategies — parent guide covering storage tier selection, quota trade-offs, and cross-origin constraints
- Syncing React State to IndexedDB for Crash Resilience — the debounced write and rollback pattern referenced above, applied to a full React state tree
- Handling QuotaExceededError When Persisting Session State — recovering gracefully when either storage tier hits its capacity ceiling