This page sits within the broader Session State Persistence & Hydration Fallbacks section, which covers every layer of durable state management from storage primitives through to hydration recovery.
Problem Statement #
A user spends forty minutes filling out a complex multi-step form. The browser tab crashes — ERR_KILLED, out-of-memory, power loss — and the state held in a React context or Zustand store is gone. On reload, the page is blank. The user abandons. This is not a theoretical edge case: browser process restarts happen multiple times a day on constrained mobile hardware.
The root cause is the gap between in-memory framework state and durable browser storage. Both localStorage and IndexedDB exist to close that gap, but each has failure modes of its own: localStorage blocks the main thread, has a hard 5 MB cap, and becomes unavailable in Safari Private Browsing and some enterprise-managed Chromium profiles. IndexedDB is async and capacious but requires explicit versioning, has a more complex error surface, and can silently stall in headless test environments. The safe solution combines both: a synchronous write-through to localStorage for immediate availability, backed by an asynchronous flush to IndexedDB for durability on large or structured payloads.
Prerequisites #
Core Implementation #
The adapter below provides a unified async interface over both APIs, with write-through to localStorage and queued async flush to IndexedDB. The SyncQueue retries failed IDB writes with exponential back-off and drops tasks after three attempts so a broken IDB instance cannot stall the queue indefinitely.
// storage-adapter.ts
import { openDB, IDBPDatabase } from 'idb';
export type StorageType = 'local' | 'indexeddb';
export interface StorageAdapter {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T): Promise<void>;
remove(key: string): Promise<void>;
}
// ── Feature detection ─────────────────────────────────────────────────────────
export function detectStorageCapabilities() {
const lsAvailable = (() => {
try {
localStorage.setItem('__probe__', '1');
localStorage.removeItem('__probe__');
return true;
} catch {
return false;
}
})();
const idbAvailable = typeof indexedDB !== 'undefined';
return { lsAvailable, idbAvailable };
}
// ── IndexedDB adapter (via `idb` wrapper) ─────────────────────────────────────
const DB_NAME = 'app-state';
const STORE = 'kv';
const DB_VERSION = 1;
let _db: IDBPDatabase | null = null;
async function getDB(): Promise<IDBPDatabase> {
if (_db) return _db;
_db = await openDB(DB_NAME, DB_VERSION, {
upgrade(db) { db.createObjectStore(STORE); },
});
return _db;
}
export const idbAdapter: StorageAdapter = {
async get<T>(key: string): Promise<T | null> {
const db = await getDB();
return (await db.get(STORE, key)) ?? null;
},
async set<T>(key: string, value: T): Promise<void> {
const db = await getDB();
await db.put(STORE, value, key);
},
async remove(key: string): Promise<void> {
const db = await getDB();
await db.delete(STORE, key);
},
};
// ── Retry-aware sync queue ────────────────────────────────────────────────────
type SyncTask = {
id: string;
type: 'set' | 'remove';
key: string;
payload?: unknown;
retries: number;
};
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 100;
export class SyncQueue {
private queue: SyncTask[] = [];
private running = false;
constructor(private adapter: StorageAdapter) {}
enqueue(task: Omit<SyncTask, 'id' | 'retries'>) {
this.queue.push({ ...task, id: crypto.randomUUID(), retries: 0 });
this.drain();
}
private async drain() {
if (this.running) return;
this.running = true;
while (this.queue.length > 0) {
const task = this.queue[0];
try {
if (task.type === 'set') await this.adapter.set(task.key, task.payload);
else await this.adapter.remove(task.key);
this.queue.shift();
} catch {
if (task.retries < MAX_RETRIES) {
task.retries++;
await delay(BASE_DELAY_MS * 2 ** task.retries);
} else {
// Telemetry hook: report persistent IDB failure
this.queue.shift();
}
}
}
this.running = false;
}
}
function delay(ms: number) { return new Promise(r => setTimeout(r, ms)); }
Architecture Note #
localStorage writes are synchronous and blocking but resolve in under 1 ms for typical state payloads. Writing there first means the state survives a tab crash even if the async IndexedDB write has not yet committed. The SyncQueue then ensures IDB is eventually consistent — if the tab crashes mid-queue, the queue is rebuilt on next open by replaying from localStorage. This mirrors a write-ahead log pattern: the fast synchronous write is the log entry; the IDB write is the page flush.
The BroadcastChannel path keeps sibling tabs consistent: when tab A commits a localStorage write, the storage event fires in tab B, which reconciles against its version counter and decides whether to accept or drop the update.
Edge Cases and Failure Modes #
| Failure | Symptom | Mitigation |
|---|---|---|
QuotaExceededError on localStorage |
Synchronous write throws; state not persisted | Pre-check quota with navigator.storage.estimate(); evict stale keys before writing |
| Private/incognito mode | localStorage.setItem throws SecurityError; IDB open may fail silently |
Wrap in detectStorageCapabilities(); fall back to in-memory TTL cache |
| IDB schema version mismatch | onblocked fires; open stalls indefinitely |
Track version in localStorage; call db.close() on versionchange event |
| Multi-tab write conflict | localStorage storage event arrives out of order |
Attach monotonic __v counter; reject updates with lower version than current |
| Corrupted IDB object store | get() returns garbled data or throws DataError |
Validate deserialized objects against a Zod schema; reset store on parse failure |
| Main-thread serialization spike | JSON.stringify of a 2 MB state object blocks for ~80 ms |
Use shallowDiff — only serialize changed keys; defer large writes to requestIdleCallback |
Advanced Variant: Debounced State Diffing with Framework Bindings #
Persisting every state update verbatim is wasteful. The pattern below diffs the previous and current state, persisting only changed keys, and integrates with React, Vue, and Svelte storage hooks.
// state-diff.ts — shallow diff; extend with structuredClone for deep comparison
export function shallowDiff<T extends Record<string, unknown>>(
prev: T,
next: T,
): Partial<T> {
const diff: Partial<T> = {};
for (const key in next) {
if (!Object.is(prev[key], next[key])) diff[key] = next[key];
}
return diff;
}
// debounced-persist.ts
import { shallowDiff } from './state-diff';
import { StorageAdapter } from './storage-adapter';
export function createDebouncedPersist<T extends Record<string, unknown>>(
adapter: StorageAdapter,
key: string,
delayMs = 300,
) {
let timer: ReturnType<typeof setTimeout> | null = null;
let lastCommitted: T | null = null;
return (next: T) => {
if (timer) clearTimeout(timer);
timer = setTimeout(async () => {
try {
const payload = lastCommitted
? shallowDiff(lastCommitted, next)
: next;
// Write-through: fast synchronous localStorage snapshot
localStorage.setItem(key, JSON.stringify(next));
// Async IDB flush: only the diff
await adapter.set(`${key}:diff`, payload);
lastCommitted = { ...next };
} catch (err) {
// Surface unsaved-work warning to the user
window.dispatchEvent(new CustomEvent('storage:sync-error', { detail: err }));
}
}, delayMs);
};
}
// ── React hook ────────────────────────────────────────────────────────────────
import { useEffect, useRef } from 'react';
export function usePersistState<T extends Record<string, unknown>>(
state: T,
adapter: StorageAdapter,
key: string,
) {
const persist = useRef(createDebouncedPersist<T>(adapter, key));
useEffect(() => { persist.current(state); }, [state]);
}
// ── Vue composable ─────────────────────────────────────────────────────────────
import { watchEffect, Ref } from 'vue';
export function usePersistStateVue<T extends Record<string, unknown>>(
stateRef: Ref<T>,
adapter: StorageAdapter,
key: string,
) {
const persist = createDebouncedPersist<T>(adapter, key);
watchEffect(() => persist(stateRef.value));
}
// ── Svelte store wrapper ───────────────────────────────────────────────────────
import { writable } from 'svelte/store';
export function persistedStore<T extends Record<string, unknown>>(
initial: T,
adapter: StorageAdapter,
key: string,
) {
const persist = createDebouncedPersist<T>(adapter, key);
const store = writable<T>(initial);
store.subscribe(v => persist(v));
return store;
}
Testing and CI/CD Validation #
Test the adapter layer in isolation using fake-indexeddb (a pure-JS in-memory IDB implementation), which eliminates flakiness from browser-native IDB in headless runners.
// storage-adapter.test.ts (Vitest / Jest)
import 'fake-indexeddb/auto'; // shims globalThis.indexedDB
import { idbAdapter, SyncQueue } from './storage-adapter';
import { vi, describe, it, expect, beforeEach } from 'vitest';
describe('idbAdapter', () => {
it('round-trips a structured object', async () => {
await idbAdapter.set('session', { userId: 'u1', draft: 'hello' });
const result = await idbAdapter.get<{ userId: string }>('session');
expect(result?.userId).toBe('u1');
});
});
describe('SyncQueue retry', () => {
it('retries up to MAX_RETRIES then drops the task', async () => {
const failingAdapter = {
get: vi.fn(),
set: vi.fn().mockRejectedValue(new Error('IDB_STALLED')),
remove: vi.fn(),
};
const queue = new SyncQueue(failingAdapter);
queue.enqueue({ type: 'set', key: 'x', payload: 42 });
// Allow micro-task queue to drain across 3 retries
await new Promise(r => setTimeout(r, 1000));
// After MAX_RETRIES the queue is empty — task was dropped, not stuck
expect((queue as any).queue).toHaveLength(0);
expect(failingAdapter.set).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
});
});
describe('quota monitoring', () => {
it('reports safe when usage is below threshold', async () => {
Object.defineProperty(navigator, 'storage', {
value: { estimate: async () => ({ quota: 1_000_000, usage: 100_000 }) },
configurable: true,
});
const { monitorQuotaUsage } = await import('./telemetry');
const result = await monitorQuotaUsage(0.8);
expect(result.safe).toBe(true);
});
});
CI fault-injection: In your pipeline, set SIMULATE_IDB_FAILURE=true via environment variable, then have the adapter’s getDB() throw on first open. Assert that the SyncQueue degrades cleanly and that localStorage still holds the last committed snapshot.
Telemetry and Quota Monitoring #
Without observability, IDB failures are silent. Wire structured metrics into a dedicated telemetry module and couple it to quota thresholds before they become QuotaExceededError at runtime.
// telemetry.ts
export interface StorageMetric {
operation: 'read' | 'write' | 'delete';
durationMs: number;
success: boolean;
payloadBytes: number;
error?: string;
}
class StorageTelemetry {
private buffer: StorageMetric[] = [];
record(m: StorageMetric) {
this.buffer.push(m);
if (this.buffer.length >= 50) this.flush();
}
private flush() {
const batch = this.buffer.splice(0);
// Non-blocking; fires even during page unload
navigator.sendBeacon('/api/storage-metrics', JSON.stringify(batch));
}
/** Flush remaining metrics on page hide. */
registerPageHideFlush() {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') this.flush();
});
}
}
export const storageTelemetry = new StorageTelemetry();
export async function monitorQuotaUsage(thresholdRatio = 0.8) {
if (!navigator.storage?.estimate) return { safe: true };
const { quota = 1, usage = 0 } = await navigator.storage.estimate();
const ratio = usage / quota;
if (ratio > thresholdRatio) {
storageTelemetry.record({
operation: 'read', durationMs: 0, success: true,
payloadBytes: 0, error: `quota_warning:${(ratio * 100).toFixed(1)}%`,
});
}
return { safe: ratio < thresholdRatio, usage, quota, ratio };
}
Graceful Degradation #
When both storage APIs are blocked — enterprise proxy, iOS PWA restriction, security policy — the application must not freeze or silently lose data. The fallback chain is: primary storage (IDB) → fast cache (localStorage) → in-memory TTL cache → user-facing unsaved-work banner.
// memory-fallback.ts
export class MemoryFallbackCache<T> {
private store = new Map<string, { value: T; expiresAt: number }>();
private readonly ttlMs: number;
constructor(ttlMs = 300_000) { this.ttlMs = ttlMs; }
set(key: string, value: T) {
this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
}
get(key: string): T | null {
const entry = this.store.get(key);
if (!entry || entry.expiresAt < Date.now()) {
this.store.delete(key);
return null;
}
return entry.value;
}
}
// safe-persist.ts — wraps any StorageAdapter with silent fallback
import { MemoryFallbackCache } from './memory-fallback';
import { StorageAdapter } from './storage-adapter';
import { storageTelemetry } from './telemetry';
export function withFallback<T>(
primary: StorageAdapter,
fallback: MemoryFallbackCache<T>,
): StorageAdapter {
return {
async get<V>(key: string): Promise<V | null> {
try {
return await primary.get<V>(key);
} catch {
return fallback.get(key) as V | null;
}
},
async set<V>(key: string, value: V): Promise<void> {
const start = performance.now();
try {
await primary.set(key, value);
storageTelemetry.record({
operation: 'write', durationMs: performance.now() - start,
success: true, payloadBytes: JSON.stringify(value).length,
});
} catch (err) {
storageTelemetry.record({
operation: 'write', durationMs: performance.now() - start,
success: false, payloadBytes: 0, error: (err as Error).message,
});
fallback.set(key, value as unknown as T);
// Notify UI layer
window.dispatchEvent(
new CustomEvent('storage:degraded', { detail: { key, err } }),
);
}
},
async remove(key: string): Promise<void> {
try { await primary.remove(key); } catch { /* best-effort */ }
},
};
}
Deep Dives Under This Topic #
The core sync patterns above solve the general case. For the specific scenario of binding React component state directly to IndexedDB with crash-recovery semantics — including transactional rollbacks and conflict resolution across React Concurrent Mode render passes — see Syncing React State to IndexedDB for Crash Resilience.
Frequently Asked Questions #
How do I handle race conditions when multiple tabs sync to the same localStorage key?
Attach a monotonic __v integer to every committed payload and listen for the native storage event (which fires in all tabs except the writer). On receipt, compare event.newValue.__v against the local version counter; reject the incoming update if its version is lower. For complex cross-tab workflows — such as keeping a shared shopping cart consistent — replace the storage event with a BroadcastChannel so you control the message format and can include a full diff rather than a serialized snapshot.
When should I choose localStorage over IndexedDB for state persistence?
Use localStorage for small, string-serializable configuration flags where synchronous read speed is the priority and payload size stays under 100 KB — feature flags, theme preference, UI toggle state. Use IndexedDB when persisting structured object graphs, binary assets (Blob, ArrayBuffer), records exceeding 1 MB, or anything that must survive schema migrations without data loss. The idb wrapper’s typed schema support makes IndexedDB practical for these cases without the raw IDBRequest ceremony.
What is the safest way to recover from a corrupted IndexedDB database?
Never silently delete and recreate the database on a single failure — that destroys data the user may still need. Instead: (1) parse the retrieved value through a Zod schema; on parse failure, increment a __parse_errors counter in localStorage; (2) after three consecutive parse failures across separate page loads, surface a modal asking the user to reset storage; (3) only then call indexedDB.deleteDatabase(DB_NAME) and re-open. Log each step via storageTelemetry so the failure sequence is visible in your analytics dashboard.
How does telemetry affect sync performance?
Telemetry writing synchronously to localStorage during a state commit doubles the blocking time for that commit. Always buffer metrics in memory and flush asynchronously — either via sendBeacon on visibilitychange or via requestIdleCallback during idle periods. The telemetry module above buffers 50 metrics before flushing; tune this constant based on your typical write frequency and sendBeacon payload size limits (~64 KB per call).
How do I integrate this with draft auto-save workflows?
Use the createDebouncedPersist hook for Draft Auto-Save & Recovery Workflows: set delayMs to 500–1000 ms so every keystroke does not trigger a write, but state is committed within one second of the user pausing. On page load, hydrate from IndexedDB first (structured, versioned), fall back to the localStorage snapshot if IDB open is still pending, and finally fall back to server-provided initial state. This three-tier hydration order is what Hydration Mismatch State Recovery covers in detail.
Related #
- Session State Persistence & Hydration Fallbacks — parent topic covering all durability layers
- Syncing React State to IndexedDB for Crash Resilience — deep dive into React-specific transactional sync
- Draft Auto-Save & Recovery Workflows — debounced persistence for form and editor state
- Hydration Mismatch State Recovery — resolving server/client state divergence on reload
- Cache Warming & Pre-Fetching on Reconnect — restoring critical state before the user interacts post-reconnect