This page narrows the concerns covered in LocalStorage & IndexedDB Sync Strategies to one specific runtime failure: a write that throws instead of silently succeeding.
The exact failure this page solves #
A checkout form calls localStorage.setItem('cart_draft', json) on every field change. In Chrome on desktop this works for years without incident, because the per-origin quota is generous. Then a support ticket arrives from a user on Safari, or from someone browsing in a private window: the cart is empty on every reload, and the console (which the user never opens) shows an uncaught QuotaExceededError. The write threw, the try block around it either didn’t exist or swallowed the error without recovery, and the session state that the rest of the app assumes is durable was never actually saved.
This is narrower than the general storage-tier trade-offs discussed in IndexedDB vs localStorage for Crash Recovery: the concern here isn’t which API to choose, it’s what to do the instant either one refuses a write. The fix is a small wrapper that treats a full quota as an expected, recoverable condition rather than an exception to let bubble up.
Zero-to-working implementation #
The wrapper below detects the error across engines, evicts old entries using a maintained LRU index, retries once, and degrades gracefully to sessionStorage and then an in-memory object if storage is truly unavailable.
// persistSessionState.ts
const LRU_INDEX_KEY = '__lru_index__';
const MAX_TRACKED_KEYS = 20;
const FIREFOX_QUOTA_NAME = 'NS_ERROR_DOM_QUOTA_REACHED';
const LEGACY_QUOTA_CODE = 22;
function isQuotaExceeded(err: unknown): boolean {
if (!(err instanceof DOMException)) return false;
// Modern engines use the standard name; Firefox historically used its
// internal error name; very old engines only expose numeric code 22.
return (
err.name === 'QuotaExceededError' ||
err.name === FIREFOX_QUOTA_NAME ||
(err as { code?: number }).code === LEGACY_QUOTA_CODE
);
}
function touchLRU(key: string): void {
const order: string[] = JSON.parse(localStorage.getItem(LRU_INDEX_KEY) ?? '[]');
const next = [...order.filter(k => k !== key), key].slice(-MAX_TRACKED_KEYS);
localStorage.setItem(LRU_INDEX_KEY, JSON.stringify(next));
}
function evictOldest(excludeKey: string, count: number): void {
const order: string[] = JSON.parse(localStorage.getItem(LRU_INDEX_KEY) ?? '[]');
// Never evict the key we are about to write — that would defeat the retry.
const evictable = order.filter(k => k !== excludeKey);
const toEvict = evictable.slice(0, count);
toEvict.forEach(k => localStorage.removeItem(k));
localStorage.setItem(LRU_INDEX_KEY, JSON.stringify(order.filter(k => !toEvict.includes(k))));
}
const memoryFallback = new Map<string, string>();
export function persistSessionState(
key: string,
value: string,
warn: (message: string) => void,
): void {
try {
localStorage.setItem(key, value);
touchLRU(key);
return;
} catch (err) {
if (!isQuotaExceeded(err)) throw err; // unrelated error — don't mask it
}
evictOldest(key, 5); // free space, then retry exactly once
try {
localStorage.setItem(key, value);
touchLRU(key);
return;
} catch {
// fall through to session/memory tier
}
try {
sessionStorage.setItem(key, value);
warn('Storage is full — this session will not survive closing the tab.');
} catch {
memoryFallback.set(key, value);
warn('Storage is full — recent changes will be lost on reload.');
}
}
Step-by-step explanation #
-
isQuotaExceeded()checks three signals.err.name === 'QuotaExceededError'covers current Chromium, Safari, and Firefox.NS_ERROR_DOM_QUOTA_REACHEDcovers older Firefox builds that named the exception after their internal error constant. The numericcode === 22covers legacy WebKit and IE, which predate the standardizednameproperty entirely. -
The first
setItemis the happy path. No wrapper overhead exists when quota is available; the function returns immediately after recording the key in the LRU index viatouchLRU. -
evictOldestexcludes the key currently being written. If the LRU index’s oldest entry happens to be the same key the caller is trying to persist right now, evicting it would remove the very data the retry is about to write, producing a write that appears to succeed but only because it deleted itself first. FilteringexcludeKeyout of the evictable set before slicing prevents this self-defeating eviction. -
The retry happens exactly once. After eviction frees space,
persistSessionStatecallssetItema second time. It does not loop, because if the underlying cause is a disk that is genuinely full — not just this origin’s quota — no amount of evicting this origin’s own keys will free enough space, and looping would just burn CPU on a doomed write. -
sessionStorageis the first fallback tier. It shares the same per-origin quota ceiling conceptually but is scoped to the tab and often has headroom whenlocalStorageis saturated with data from other sessions; a caught exception there moves to the final tier. -
The in-memory
Mapis the last resort. It guarantees the current tab keeps working for the rest of its lifetime even when both storage APIs refuse writes — critical in Safari private browsing, where both tiers can be capped at effectively zero. -
warn()is a required parameter, not optional. The wrapper is designed so callers cannot silently ignore degraded durability; the caller supplies a UI-level warning function (a toast, a banner, a status line) so the user knows their data is at risk before they close the tab.
Proactive quota monitoring #
Waiting for a thrown error is reactive. Pair the wrapper with a startup check that estimates remaining headroom and requests durable storage before the user ever hits the cap:
async function checkStorageHealth(warn: (message: string) => void): Promise<void> {
if (!navigator.storage?.estimate) return; // unsupported in this engine
const { usage = 0, quota = 0 } = await navigator.storage.estimate();
if (quota > 0 && usage / quota > 0.85) {
warn('Storage is nearly full; older session data may be evicted soon.');
}
// Best-effort request — browsers grant this based on engagement heuristics
// and may silently refuse; it never throws, so no try/catch is required.
if (navigator.storage?.persist) {
const granted = await navigator.storage.persist();
if (!granted) warn('Persistent storage was not granted for this session.');
}
}
Run checkStorageHealth once per session, early enough that the warning banner can appear before the user has typed anything worth losing.
Edge cases #
| Scenario | Symptom | Mitigation |
|---|---|---|
| Safari private browsing | setItem throws QuotaExceededError on the very first write, even for a few bytes |
Skip straight to the sessionStorage/memory tiers by detecting failure on a small canary write at startup, rather than assuming localStorage is usable |
| Cross-browser error identity | A catch block written only for err.name === 'QuotaExceededError' misses older Firefox and legacy WebKit |
Always check name, the Firefox constant, and code === 22 together, as shown in isQuotaExceeded |
| Evicting the key being written | LRU eviction deletes the same key the caller is about to persist, producing a write that only “succeeds” because it erased itself | Exclude the target key from the evictable set before computing the slice to delete |
| Quota vs. disk-full | Both conditions throw the identical QuotaExceededError; there is no script-visible way to tell an origin cap from an actually full disk |
Treat them identically — evict, retry once, then degrade — since the recovery path is the same regardless of root cause |
Verification steps #
-
DevTools quota override. In Chrome DevTools, open Application → Storage, enable “Simulate custom storage quota,” and set it to a few kilobytes. Trigger a write larger than the override and confirm the console shows the wrapper’s
warn()message instead of an uncaught exception. -
Confirm the LRU index shrinks. After a forced eviction, inspect
localStorage.getItem('__lru_index__')in the console and confirm it no longer contains the keys that were deleted, and that the key just written is present. -
Simulate private mode. Open a genuinely private/incognito window, run
persistSessionState('probe', 'x', console.warn), and confirm it falls through to the memory tier without throwing, by checking that execution reaches the finalwarncall rather than an unhandled rejection in the console. -
Assert
navigator.storage.persist()is called. Set a breakpoint or aconsole.loginsidecheckStorageHealthand confirm it runs once on app startup, and that the returned boolean is logged so you can see whether the browser actually granted persistence. -
Regression test in CI. Stub
Storage.prototype.setItemto throw a realDOMException('', 'QuotaExceededError')on the first two calls and succeed on the third, then assertpersistSessionStatestill resolves the value intolocalStorageafter eviction and retry.
Frequently asked questions #
Why does Safari throw QuotaExceededError so much sooner than Chrome?
Safari enforces a materially smaller per-origin cap on localStorage than Chromium-based browsers, and in private browsing windows the effective quota is close to zero, so even a small JSON payload for session state can throw immediately where the same code runs for years without incident in Chrome.
Does calling navigator.storage.persist() guarantee my data survives?
No. It is a best-effort request that the browser grants or refuses based on engagement heuristics such as bookmarking, installation, or notification permissions, and it can be silently refused. Keep the retry-then-fallback wrapper in place regardless of whether persistence was granted, since it is the only guaranteed recovery path.
Should I catch QuotaExceededError differently from a full disk error?
Not in practice. Both conditions surface as the same DOMException name in every major engine, and script has no reliable way to distinguish an origin-level quota ceiling from the operating system’s disk actually being full. Treat them identically: evict, retry once, then degrade to sessionStorage and memory.
Related #
- LocalStorage & IndexedDB Sync Strategies — parent guide covering storage tier selection and quota trade-offs
- IndexedDB vs localStorage for Crash Recovery — choosing between the two APIs before quota pressure becomes a concern
- Syncing React State to IndexedDB for Crash Resilience — a debounced hook that applies the same LRU-eviction principle to larger state trees