Storage Quotas & Eviction Policies

Modern web applications increasingly rely on client-side persistence to deliver seamless offline experiences. However, browser storage is not infinite, and the mechanisms governing allocation and cleanup are highly dynamic. When an origin exceeds its budget or a device runs low on disk, the browser reclaims space silently — an IndexedDB database that opened full yesterday can open empty today, with no exception thrown on the read path. For frontend engineers and PWA developers, understanding how rendering engines manage disk pressure is the difference between a resilient offline app and one that quietly loses user data. This guide is part of Browser Storage Fundamentals & Quotas and details the heuristics behind storage allocation, cross-vendor quota variations, persistence tiers, and production-ready strategies for monitoring and handling eviction events.

Storage eviction decision flow A decision tree showing how a browser decides whether to evict an origin's storage based on disk pressure, persistence grant, and engagement. Disk under pressure? OS reclaim triggered No: data retained quota still available Persistent granted? navigator.storage.persist() Yes: exempt survives until user clears Best-effort: evicted least-recently-used origin first

The StorageManager API: Quota, Usage, and Persistence

The StorageManager interface, exposed at navigator.storage in a secure context, is the canonical way to query and control an origin’s storage budget. It surfaces three methods that together cover measurement (estimate), durability requests (persist), and durability checks (persisted). All three return Promises and are safe to call from a Window or a Worker context, including inside a service worker.

Method Signature Returns Purpose
estimate() estimate(): Promise<StorageEstimate> { usage, quota, usageDetails? } in bytes Read current consumption and the negotiable budget for the origin’s temporary storage group
persist() persist(): Promise<boolean> true if the origin is now persistent Request a durability upgrade so the origin is exempt from automatic eviction
persisted() persisted(): Promise<boolean> true if already persistent Check the current persistence tier without requesting a change

The StorageEstimate object returned by estimate() carries two guaranteed numeric fields and one optional one:

Field Type Meaning
usage number (bytes) Approximate bytes the origin currently occupies across IndexedDB, CacheStorage, and OPFS
quota number (bytes) Approximate ceiling the browser is willing to grant right now — revised as global free space changes
usageDetails Record<string, number> (Chromium only) Non-standard per-API breakdown, e.g. { indexedDB, caches, serviceWorkerRegistrations }

Two properties of these values trip engineers up. First, both usage and quota are deliberately imprecise — browsers pad and quantize them to resist storage-based fingerprinting, so never assert on exact byte counts. Second, quota is a moving target: it tracks a fraction of free disk, so a value read at boot can shrink after the user fills their drive with unrelated files. Re-query before any large write rather than caching the number for the session.

Persistence is a per-origin boolean tier, not a per-database flag. A granted origin keeps everything in its temporary storage group — IndexedDB, the Cache API for Static Assets, and the Origin Private File System (OPFS) — exempt from automatic eviction. Synchronous key-value stores like localStorage vs sessionStorage sit outside these buckets and are governed separately, which is exactly why they should never hold data you cannot afford to lose.

Understanding Browser Storage Allocation

Modern browsers implement dynamic storage models that scale based on available disk space and user engagement metrics. Unlike legacy fixed-capacity models, contemporary browsers rely on heuristic algorithms to determine how much data an origin can safely persist. These algorithms factor in global device storage, origin visit frequency, and whether the application is installed as a PWA. Engineers must treat available storage as a shared, volatile resource rather than a guaranteed allocation, baking quota management into the application lifecycle rather than treating it as an afterthought.

The primitives you choose determine which tier your data lands in. Properly leveraging the APIs covered in Understanding Web Storage APIs ensures your application requests the appropriate persistence level for critical state. IndexedDB and the Cache API fall under the temporary (best-effort) tier by default and can be upgraded to durable storage via the StorageManager API; localStorage cannot be upgraded at all and remains subject to browser- and OS-level eviction under pressure.

Cross-Browser Quota Variations

Storage limits are not standardized across rendering engines. Each vendor applies distinct heuristics, compression strategies, and background throttling rules. For a detailed breakdown of how Browser Storage Limits Across Chrome, Firefox, and Safari differ in practice, consult our comparative analysis. The table below summarizes the headline behaviors engineers most often trip over.

Engine Temporary storage group cap Per-origin behavior Eviction trigger
Chrome / Chromium ~60% of free disk Fraction of shared pool LRU under disk pressure
Firefox ~20% of total disk Lesser of 10 GB or 10% of group LRU; persist() opts out
Safari (WebKit) ~1 GB then prompts Per-origin, user-extendable 7-day inactivity wipe (non-installed)
Edge (Chromium) ~60% of free disk Matches Chrome LRU under disk pressure

Chromium-based browsers typically allocate up to ~60% of available free disk space for the temporary storage group shared across IndexedDB, CacheStorage, and OPFS, while Firefox caps the group at approximately 20% of total disk space. Safari imposes stricter per-origin caps and aggressively purges data after seven days of user inactivity for non-installed origins — a behavior so disruptive to PWAs that it has its own dedicated playbook in The iOS Safari 7-Day Storage Eviction Workaround. Understanding these variations is critical for cross-platform PWA reliability, especially when caching large media assets or synchronizing offline databases across mobile and desktop environments.

Eviction Tiers and Persistence Guarantees

Browsers categorize stored data into priority tiers to manage disk pressure efficiently. Under the best-effort (temporary) tier, an origin’s data is a candidate for eviction whenever the operating system asks the browser to reclaim space; the browser evicts entire origins, least-recently-used first, rather than picking individual records. When persistent storage is granted, the origin is exempt from that automatic eviction unless the user explicitly clears site data or the device runs completely out of disk space.

Persistence is not a switch you flip unconditionally. Browsers gate navigator.storage.persist() behind engagement signals: an installed PWA, a high site-engagement score, a previously granted notification permission, or a bookmark all raise the odds of a grant. Chromium may auto-grant silently; Firefox shows a permission prompt; Safari requires the request to originate from a user gesture. Always inspect the boolean return value and design a degraded path for denial rather than assuming durability.

Storage persistence tiers and the quota bucket IndexedDB, CacheStorage, and OPFS share one temporary storage group measured by estimate(). By default it is best-effort and evictable LRU under disk pressure; persist() promotes the whole group to a persistent tier exempt from automatic eviction. localStorage and sessionStorage sit outside the quota bucket and are governed separately. Temporary storage group — the quota bucket measured by navigator.storage.estimate() Persistent tier — exempt from auto-eviction survives until the user clears site data navigator.storage.persist() Best-effort tier (default) — evictable entire origin dropped LRU-first under disk pressure IndexedDB CacheStorage OPFS quota shared across all three APIs Outside the bucket separate storage · not in the quota localStorage sessionStorage own ~5 MB limit each governed separately cannot be upgraded

Implementation Walkthrough

The following patterns demonstrate how to request persistent storage, monitor quota thresholds, and gracefully handle quota exhaustion in offline-first applications. Implement them in this order: request persistence early, monitor continuously, and fall back deterministically when a write cannot be honored.

  1. Request persistent storage during initialization. Call navigator.storage.persist() once at startup (from a user gesture on Safari) and record the boolean result so later code can branch on durability.
  2. Sample quota before large writes. Call navigator.storage.estimate() and compute the usage ratio; prune proactively above 80% to leave headroom for the pending write.
  3. Wrap writes in quota-aware error handling. Catch QuotaExceededError on the transaction, run a cleanup pass, and fall back to a server sync rather than losing the data.

Requesting Persistent Storage & Monitoring Quota

async function manageStorageQuota(): Promise<void> {
  if (navigator.storage && navigator.storage.persist) {
    // persisted() is cheap; only request a grant if we don't have one
    const alreadyPersisted = await navigator.storage.persisted();
    const isPersisted = alreadyPersisted || (await navigator.storage.persist());
    console.log(`Persistent storage granted: ${isPersisted}`);
  }

  const { usage = 0, quota = 0 } = await navigator.storage.estimate();
  const usagePercent = quota > 0 ? (usage / quota) * 100 : 0;
  console.log(`Storage usage: ${usagePercent.toFixed(2)}%`);

  if (usagePercent > 80) {
    console.warn('Approaching quota limit. Implementing data pruning strategy.');
    await pruneOldCacheEntries();
  }
}

Calling persisted() before persist() avoids re-triggering a permission prompt in Firefox on every load, and short-circuits the engagement heuristics once durability is already in place.

Concurrency and Lifecycle Considerations

Eviction does not respect your transaction boundaries. If the operating system reclaims space while a write is mid-flight, the transaction aborts and onabort fires — a distinct code path from the onerror a QuotaExceededError follows. Always wire both. When multiple tabs share an origin, an eviction or a persist() grant in one tab affects all of them, so coordinate critical writes through the Web Locks API for Cross-Tab Coordination to avoid two tabs racing to repopulate the same evicted store.

Because eviction never throws on the read path — a missing database simply opens empty — your code must treat absence as a recoverable state. Add an integrity probe at startup that confirms the expected object stores exist and re-hydrates from the network when they do not. Treat the post-eviction state as a cold start: re-run your schema check, re-request persistence, and re-sync rather than assuming any prior invariant holds. Pairing that probe with periodic estimate() sampling gives you both a leading indicator (rising usage) and a lagging indicator (vanished data) of storage pressure.

Error Handling & Retry Strategy

When disk pressure mounts, browsers silently evict data from non-persistent origins, and large writes fail loudly with a QuotaExceededError on the transaction. The robust pattern is to catch that error at the transaction level, run a cleanup pass to free headroom, and — if the write still cannot be honored — fall back to a server sync so the data is never lost. For a deeper diagnostic workflow, review our guide on Debugging Storage Eviction in Progressive Web Apps.

async function saveCriticalState(
  db: IDBDatabase,
  data: Record<string, unknown>,
): Promise<void> {
  return new Promise<void>((resolve, reject) => {
    const tx = db.transaction('state', 'readwrite');
    const store = tx.objectStore('state');

    tx.oncomplete = () => resolve();
    tx.onabort = () => reject(tx.error ?? new DOMException('aborted', 'AbortError'));
    tx.onerror = () => {
      const err = tx.error;
      if (err && err.name === 'QuotaExceededError') {
        console.error('Storage quota exceeded. Falling back to server sync.');
        // Kick off background recovery without blocking the current call
        syncToCloudFallback(data).then(() => clearNonEssentialCache());
      }
      reject(err);
    };

    store.put(data, 'critical_state');
  });
}

Because a QuotaExceededError is not transient — retrying the identical write into a full bucket fails again — retries must be paired with reclamation. Prune least-valuable entries first, verify with a fresh estimate(), and only then retry; if a single record is larger than the entire remaining budget, skip straight to the network fallback rather than looping.

Browser Compatibility Matrix

The StorageManager API is broadly available, but the persistence and estimate behaviors diverge enough that you should feature-detect every method before calling it. The versions below mark where each capability became reliably usable.

Capability Chrome / Edge Firefox Safari (WebKit) Known bugs / caveats
estimate() 61+ 51+ 15.2+ Safari long omitted the method; older iOS returns tiny or zero quota. usageDetails is Chromium-only
persist() 55+ 55+ 15.4+ Chromium auto-grants silently on engagement; Firefox prompts; Safari needs a user gesture and does not stop the 7-day timer for non-installed origins
persisted() 55+ 55+ 15.4+ Returns false when persistence was never requested — not an error state
7-day inactivity eviction not applied not applied iOS/iPadOS 16–17 Installed (home-screen) PWAs are exempt; ordinary tabs are wiped after 7 days of no interaction

The single most important compatibility note for mobile teams: iOS Safari 16 and 17 enforce a hard seven-day cap on script-writable storage for non-installed origins, and no combination of persist() calls stops the timer in a normal browser tab. Only adding the PWA to the home screen — or continuous user interaction — keeps the clock reset, which is why the iOS Safari workaround leans on install prompts and server-side rehydration rather than the persistence API alone.

Performance & Scale Considerations

Quota management has its own runtime cost, and naive implementations create the very jank they were meant to avoid. Keep these constraints in mind as data volume grows:

Common Pitfalls & Troubleshooting

Avoid these frequent implementation mistakes that lead to data loss, degraded UX, or silent failures in offline-first architectures:

Frequently Asked Questions

What triggers browser storage eviction?

Eviction is primarily triggered by system-wide disk pressure, prolonged inactivity of an origin, or explicit user actions like clearing site data. Browsers prioritize evicting non-persistent, least-recently-used origins first. Granting persistent storage exempts an origin from automatic eviction.

How can I prevent my PWA data from being evicted?

Call navigator.storage.persist() to request durable storage. Browsers typically grant this to PWAs with high user engagement, installed status, or active service workers. Always handle denial gracefully, and on iOS Safari follow The iOS Safari 7-Day Storage Eviction Workaround.

Does localStorage count toward the quota reported by navigator.storage.estimate()?

No. localStorage is managed separately from the Storage API quota bucket. navigator.storage.estimate() reports usage for IndexedDB, CacheStorage, and the Origin Private File System. localStorage has its own roughly 5 MB per-origin limit enforced independently.

How do I monitor remaining storage space programmatically?

Use the StorageManager API via navigator.storage.estimate(). It returns a Promise resolving to an object containing usage (bytes used) and quota (total bytes available) for the current origin’s temporary storage group. Re-query it before large writes rather than caching the value.

Related