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.
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.
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.
- 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. - 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. - Wrap writes in quota-aware error handling. Catch
QuotaExceededErroron 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:
estimate()is not free at scale. On origins holding hundreds of megabytes, resolvingestimate()can take tens of milliseconds because the browser tallies usage across stores. Sample it on a throttled cadence — before large writes and on an interval — not on every keystroke or render.- Prune in batches, off the main thread. Deleting thousands of stale records in one synchronous loop blocks the UI. Move pruning and large writes into a Web Worker and chunk deletes into bounded IndexedDB transactions so eviction cleanup never competes with rendering.
- Leave headroom, don’t fill to the ceiling. Writing right up to the reported
quotainvites aQuotaExceededErrorbecause the estimate is padded and the real ceiling can drop mid-write. Target a soft threshold (e.g. 80%) and prune proactively so a burst write always has room. - Coordinate reclamation across tabs. Multiple tabs pruning the same origin simultaneously waste work and can abort each other’s transactions. Gate the cleanup pass behind a shared lock so exactly one tab reclaims at a time.
Common Pitfalls & Troubleshooting
Avoid these frequent implementation mistakes that lead to data loss, degraded UX, or silent failures in offline-first architectures:
- Assuming
localStorageprovides persistent storage guarantees across browser restarts.localStorageis susceptible to eviction under disk pressure. Migrate critical offline state to IndexedDB. - Failing to handle
QuotaExceededErrorduring bulk IndexedDB writes. Always wrap bulk transactions intry/catchblocks and implement atomic rollback or fallback sync mechanisms. - Ignoring
navigator.storage.estimate()before initiating large asset downloads. Check remaining quota before fetching large media or dataset payloads to prevent mid-download failures. - Not implementing a fallback sync mechanism when persistent storage requests are denied. Browsers may deny
navigator.storage.persist()based on engagement metrics. Design your app to degrade gracefully by syncing to a remote backend or using ephemeral caching. - Writing to storage synchronously on the main thread, causing jank during eviction checks. Offload quota estimation, pruning, and large writes to Web Workers or use asynchronous IndexedDB transactions to maintain UI responsiveness.
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
- Browser Storage Limits Across Chrome, Firefox, and Safari — the per-engine quota numbers and eviction rules in detail.
- Debugging Storage Eviction in Progressive Web Apps — diagnosing and recovering from silent data loss.
- The iOS Safari 7-Day Storage Eviction Workaround — keeping PWA state alive under WebKit’s inactivity wipe.
- Storage Partitioning & Privacy Controls — how privacy engines partition and clear storage independently of quota.
- Browser Storage Fundamentals & Quotas — the parent guide covering every browser storage primitive.