How to Handle localStorage QuotaExceededError
When building offline-first applications or Progressive Web Apps, synchronous localStorage writes frequently hit hard engine limits (~5 MB per origin). An unhandled quota violation crashes state persistence, blocks the main thread, and breaks user sessions. This guide details exact error interception, root-cause analysis, and production-safe fallback implementations. It assumes the synchronous model and quota mechanics covered in Understanding Web Storage APIs; start there if you need the API surface before tackling recovery.
Identifying the QuotaExceededError
The localStorage API operates synchronously and lacks native buffering. When the per-origin allocation pool is exhausted, the browser throws a DOMException with name === 'QuotaExceededError'. Unlike asynchronous storage layers, localStorage does not support partial writes or deferred execution, so the throw happens inline on the setItem call and must be caught there.
Detection workflow:
- Intercept the
DOMExceptionsynchronously around everylocalStorage.setItem()call. - Watch service workers and background sync tasks especially, where a silent write failure can corrupt offline-first state without a visible error.
- Recognize that Chromium, WebKit, and Gecko all enforce strict caps that reject the write immediately rather than evicting older data for you.
One Safari-specific trap: in private browsing the quota is effectively zero, so the first write throws QuotaExceededError even though nothing is stored. Treat that case as “storage unavailable,” not “storage full.”
Root cause: serialization overhead and shared pools
Quota exhaustion rarely stems from raw text volume alone. JavaScript object serialization inflates payload size beyond the visible character count, and the limit is measured in UTF-16 code units — roughly two bytes per character — so the effective budget is smaller than naive byte math predicts. Unbounded arrays, missing TTL (time-to-live) logic, and cumulative session bloat compound across visits. Shared origin contexts — cross-origin iframes and subdomain proxies on the same registrable origin — compete for the identical allocation pool, so one component’s growth can starve another’s writes.
Understanding the synchronous blocking model and origin-scoped limits is essential before mitigating. The payload-inflation mechanics are detailed in Data Serialization & Deserialization, and the per-engine ceilings that trigger the throw are mapped in Storage Quotas & Eviction Policies.
Step-by-step fix: a safe write wrapper with fallback
Production systems must never let a quota violation propagate uncaught. The fix has four steps: wrap the write in try/catch, evict the oldest key on QuotaExceededError, retry once, and route to IndexedDB if the retry still fails. The wrapper below is complete and copy-pasteable.
interface WriteResult {
success: boolean;
fallback?: 'indexeddb';
}
/**
* Safely writes to localStorage with single-key eviction and IndexedDB fallback.
*/
async function safeLocalStorageSet(
key: string,
value: unknown,
): Promise<WriteResult> {
const serialized = JSON.stringify(value);
try {
localStorage.setItem(key, serialized);
return { success: true };
} catch (e) {
if (e instanceof DOMException && e.name === 'QuotaExceededError') {
// Step 1: basic LRU-style eviction — drop the oldest key (index 0).
const oldestKey = localStorage.key(0);
if (oldestKey && oldestKey !== key) {
localStorage.removeItem(oldestKey);
}
// Step 2: retry the write once.
try {
localStorage.setItem(key, serialized);
return { success: true };
} catch (retryErr) {
if (
retryErr instanceof DOMException &&
retryErr.name === 'QuotaExceededError'
) {
// Step 3: still full — route overflow to IndexedDB.
console.error('Quota exceeded post-eviction. Routing to IndexedDB.');
return routeToIndexedDB(key, value);
}
throw retryErr;
}
}
// Re-throw non-quota exceptions (SecurityError, TypeError).
throw e;
}
}
/**
* Persists the key-value pair to IndexedDB as a quota-overflow fallback.
*/
function routeToIndexedDB(key: string, value: unknown): Promise<WriteResult> {
return new Promise((resolve, reject) => {
const request = indexedDB.open('ls-overflow', 1);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains('overflow')) {
db.createObjectStore('overflow', { keyPath: 'key' });
}
};
request.onsuccess = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
const tx = db.transaction('overflow', 'readwrite');
tx.objectStore('overflow').put({ key, value });
tx.oncomplete = () => resolve({ success: true, fallback: 'indexeddb' });
tx.onerror = () => reject(tx.error);
};
request.onerror = (event) =>
reject((event.target as IDBOpenDBRequest).error);
});
}
The single-key eviction here is deliberately minimal. For a richer least-recently-used policy, track an access timestamp alongside each value and evict by oldest timestamp rather than by index position. The transaction lifecycle the fallback relies on is covered in IndexedDB Transaction Management.
Verification: confirm the fix worked
Prove each branch of the wrapper fires correctly before shipping.
-
Simulate exhaustion. Pre-fill storage with a probe loop until it throws, then call your wrapper:
// Fill localStorage until it throws, then verify recovery. function fillToQuota(): void { const chunk = 'x'.repeat(1024 * 256); // 256 KB blocks try { for (let i = 0; ; i++) localStorage.setItem(`__fill_${i}`, chunk); } catch { /* quota reached — expected */ } } fillToQuota(); const result = await safeLocalStorageSet('critical', { ok: true }); console.assert(result.success, 'wrapper must recover from a full store'); -
Assert the fallback path. When
localStorageis saturated and eviction cannot free enough room, confirmresult.fallback === 'indexeddb'and that the record is readable from thels-overflowdatabase in DevTools underApplication > Storage > IndexedDB. -
Inspect with DevTools. Open
Application > Storage > Local Storage, watch keys disappear during eviction, and confirm the critical key is present after recovery. -
Clean up probes. Remove the
__fill_*keys so the simulation does not leave the store full for the next test run.
Edge cases and a proactive fallback
Reactive recovery is the floor, not the ceiling. Polling storage health lets you reject non-critical writes before the throw, avoiding the synchronous stall entirely. Note that localStorage usage is not reported by navigator.storage.estimate() — that figure covers the IndexedDB and Cache pool — so use it as a signal for overall pressure, and budget the ~5 MB Web Storage cap separately.
interface HealthReport {
usageRatio: number;
isCritical: boolean;
}
/**
* Monitors the broader storage pool and triggers cleanup past a threshold.
*/
async function validateStorageHealth(): Promise<HealthReport> {
const { usage = 0, quota = 1 } = await navigator.storage.estimate();
const usageRatio = usage / quota;
if (usageRatio > 0.8) {
await triggerBackgroundCleanup();
}
return { usageRatio, isCritical: usageRatio > 0.9 };
}
async function triggerBackgroundCleanup(): Promise<void> {
// Implement TTL-based key pruning or cache invalidation here.
console.log('Storage utilization high. Initiating background cleanup.');
}
Further edge cases worth handling:
- Private browsing (Safari/Firefox). The first write may throw
QuotaExceededErrorwith a near-zero quota. Detect it with a probe write at startup and degrade to in-memory state rather than retrying. Distinguishing the two failure modes is covered in Understanding Web Storage APIs. - Compress before falling back. For text-heavy payloads, compressing values can recover enough headroom to avoid IndexedDB altogether; only spill to IndexedDB when compressed retry still throws.
- Request persistence. Call
navigator.storage.persist()so the broader pool resists eviction under pressure; combine with the eviction model in Storage Quotas & Eviction Policies.
Frequently Asked Questions
Why does the first localStorage write throw QuotaExceededError in Safari private mode?
Safari grants an effectively zero quota in private browsing, so the initial setItem throws even though nothing is stored. Treat this as “storage unavailable” rather than “storage full”: probe once at startup with a write-and-remove, and fall back to in-memory state when it throws.
Does navigator.storage.estimate() include my localStorage usage?
No. estimate() reports the IndexedDB and Cache Storage pool, not the separate ~5 MB Web Storage allocation. Use it to gauge overall pressure, but track Web Storage headroom yourself by summing serialized value lengths, budgeting roughly two bytes per UTF-16 character.
Should I retry the write or fall back to IndexedDB first?
Try a single eviction-and-retry first, since freeing one stale key is cheaper than an async IndexedDB round-trip. Only route to IndexedDB when the retry still throws. See IndexedDB Transaction Management for the transaction lifecycle the fallback depends on.
Related
- Understanding Web Storage APIs — the synchronous model, quota mechanics, and full error taxonomy behind this fix.
- Storage Quotas & Eviction Policies — the per-engine limits and eviction order that trigger the error.
- Data Serialization & Deserialization — how payload inflation consumes quota faster than byte math suggests.
- Debugging Storage Eviction in Progressive Web Apps — diagnosing data that vanishes under pressure on mobile.