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:

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.”

QuotaExceededError recovery decision flow A flowchart showing how a failed setItem is caught, the oldest key evicted, the write retried, and overflow routed to IndexedDB when retry still fails. setItem() try block Quota error? catch DOMException Evict oldest + retry write Retry fails still full IndexedDB overflow store

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.

  1. 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');
    
  2. Assert the fallback path. When localStorage is saturated and eviction cannot free enough room, confirm result.fallback === 'indexeddb' and that the record is readable from the ls-overflow database in DevTools under Application > Storage > IndexedDB.

  3. Inspect with DevTools. Open Application > Storage > Local Storage, watch keys disappear during eviction, and confirm the critical key is present after recovery.

  4. 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:

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