Resolving QuotaExceededError: Browser Storage Limits Across Chrome, Firefox, and Safari

Offline-first applications frequently fail during bulk state synchronization or asset caching. Developers encounter QuotaExceededError (DOMException 22) or experience silent data truncation when writing to IndexedDB or the Cache API. The failure manifests inconsistently because the Storage Quotas & Eviction Policies that govern allocation vary by engine implementation, device capacity, and user interaction state. For the foundational model behind these limits, review Browser Storage Fundamentals & Quotas.

Per-engine storage allocation comparison A comparison of how Chrome, Firefox, and Safari size the temporary storage group and trigger eviction. Chrome / Edge ~60% of free disk shared pool LRU eviction under disk pressure Firefox ~20% of disk 10 GB origin cap LRU eviction persist() opts out Safari ~1 GB then prompt 7-day inactivity wipe

Problem Statement: Cross-Browser QuotaExceededError and Silent Data Loss

Offline-first applications frequently fail during bulk state synchronization or asset caching. Developers encounter QuotaExceededError (DOMException 22) or experience silent data truncation when writing to IndexedDB or the Cache API. The failure manifests inconsistently because storage limits vary by engine implementation, device capacity, and user interaction state.

Common Symptoms:

Root Cause: Divergent Quota Management & Eviction Strategies

Each browser enforces distinct storage boundaries and lifecycle rules. The Storage Standard defines a single temporary “best-effort” bucket per origin and a persisted flag that exempts it from eviction, but it deliberately leaves the size of that bucket and the eviction heuristics to each implementation. That latitude is exactly why a payload that writes cleanly on a Chrome desktop can throw on an iPhone.

Engine-Specific Breakdown:

Browser Group quota Per-origin limit Persistence Inactivity eviction
Chrome / Edge ~60% free disk Fraction of pool Often auto-granted None (disk-pressure only)
Firefox ~20% total disk Lesser of 10 GB / 10% group Prompted None (disk-pressure only)
Safari 16 ~1 GB then prompt User-extendable User gesture required 7 days (non-installed)
Safari 17+ No single hard cap User-extendable User gesture required 7 days (non-installed)

Understanding these mechanics is critical for implementing robust quota handling. The Safari inactivity wipe in particular deserves its own mitigation strategy, covered in The iOS Safari 7-Day Storage Eviction Workaround. Note that this 7-day clearing is enforced by Intelligent Tracking Prevention and operates independently of disk quota — a topic explored under Storage Partitioning & Privacy Controls.

Step-by-Step Fix: Quota-Aware Write Implementation

Implement a defensive write pipeline that checks available quota, requests persistence, and handles overflow gracefully before committing data to IndexedDB or the Cache API.

  1. Query current usage and available quota. Call navigator.storage.estimate() to retrieve usage and quota. Calculate remaining capacity before initiating bulk writes to prevent mid-transaction failures.
  2. Request persistent storage. Invoke navigator.storage.persist() early in the app lifecycle. On Safari, this must be triggered directly from a user gesture (click/tap). Without persistence, data remains temporary and subject to automatic eviction.
  3. Wrap write operations in a quota-aware async handler. Use try/catch blocks around database transactions. Implement chunked writes and fallback to in-memory buffers or compressed formats when approaching 80% of available capacity.
  4. Handle QuotaExceededError explicitly. Catch DOMException with name === 'QuotaExceededError' or legacy code === 22. Trigger immediate cache/DB cleanup routines, log telemetry, and notify the user to free disk space or reduce sync frequency.
/**
 * Production-safe quota-aware write handler for IndexedDB.
 * Uses the native IDBDatabase API; no wrapper library required.
 */
async function safePersistData(db, storeName, payload) {
  const { usage = 0, quota = 0 } = await navigator.storage.estimate();
  const remaining = quota - usage;
  const payloadSize = new Blob([JSON.stringify(payload)]).size;

  // Proactive cleanup at 80% capacity threshold
  if (remaining > 0 && payloadSize > remaining * 0.8) {
    console.warn('[Storage] Approaching quota limit. Initiating background cleanup.');
    await clearOldEntries(db, storeName);
  }

  // Request persistent storage if not already granted
  if (navigator.storage.persist && !(await navigator.storage.persisted())) {
    const granted = await navigator.storage.persist();
    if (!granted) {
      console.warn('[Storage] Persistent storage denied. Data subject to browser eviction.');
    }
  }

  return new Promise((resolve, reject) => {
    const tx = db.transaction(storeName, 'readwrite');
    const store = tx.objectStore(storeName);

    store.put(payload, 'current_state');

    tx.oncomplete = async () => {
      const updated = await navigator.storage.estimate();
      resolve({ success: true, usage: updated.usage });
    };

    tx.onerror = () => {
      const err = tx.error;
      if (err && (err.name === 'QuotaExceededError' || err.code === 22)) {
        console.error('[Storage] QuotaExceededError triggered. Executing emergency eviction.');
        clearOldEntries(db, storeName).catch(console.error);
        reject(
          new Error('Storage quota exceeded. Fallback cleanup executed. Retry with reduced payload.'),
        );
      } else {
        reject(err);
      }
    };
  });
}

async function clearOldEntries(db, storeName) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction(storeName, 'readwrite');
    tx.objectStore(storeName).clear();
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

Validation: Cross-Browser Quota Testing & Verification

Verify implementation stability by simulating low-disk conditions and monitoring quota reporting accuracy across target browsers.

Edge Cases & Fallback Approaches

When persist() is denied — common on browsers with low engagement scores — fall back to a server-authoritative model: treat the local store as a disposable cache and re-sync from the backend whenever an integrity probe finds it missing. For payloads that are simply too large for a constrained origin, chunk them across multiple keys and compress before writing, or move bulk binary data to the Origin Private File System (OPFS), which is sized from the same pool but avoids the per-record overhead of structured cloning. On Safari specifically, do not rely on a successful estimate() reading alone; the reported quota can shrink after the user dismisses the storage prompt, so always keep the catch path live.

Frequently Asked Questions

Why does the same write succeed in Chrome but fail in Safari?

Chrome sizes the temporary storage group at roughly 60% of free disk and rarely prompts, whereas Safari grants about 1 GB per origin before prompting and applies stricter per-origin accounting. A payload that fits Chrome’s generous pool can exceed Safari’s initial allocation and throw QuotaExceededError. Always gate large writes on navigator.storage.estimate().

What is the difference between QuotaExceededError name and code 22?

They identify the same DOMException. Modern engines expose it via the name property ('QuotaExceededError'); older code paths and some legacy browsers expose the numeric code value 22. Check both (err.name === 'QuotaExceededError' || err.code === 22) for maximum compatibility.

Does requesting persistent storage increase my quota?

No. navigator.storage.persist() only exempts the origin from automatic eviction; it does not raise the byte ceiling. To store more data you still need free disk space, and on Safari the user must approve the larger allocation through the storage prompt.

Related