Web Platform · Browser Storage

Quota Accounting and Eviction Under Storage Pressure

How browsers measure an origin's storage usage, where the quota figure comes from, what persistence changes, and how a victim is chosen when the disk fills.

Every storage API in the browser shares one budget per origin: IndexedDB records, Cache API responses, origin private file system files, and the service worker registration all draw from the same pool, and the pool is not a fixed size. It is derived from the volume’s capacity, it varies between browsers and between a normal and a private window, and the browser reserves the right to delete the whole thing when space runs short. Systems that treat navigator.storage.estimate() as a constant, or that assume a successful write is permanent, fail in ways that look like data corruption.

Usage is attributed to a storage key, net of overhead

navigator.storage.estimate() resolves with an object containing usage and quota, both in bytes. Usage is not the sum of the byte lengths of the values that were stored. It includes the browser’s own bookkeeping: index and record overhead in IndexedDB, per-entry headers in the Cache API, block allocation in the file system. A store holding a million short strings measures far more than the strings themselves, and a store from which most records have been deleted may not shrink immediately, because the underlying storage keeps space allocated after deletion.

Some implementations add a usageDetails record breaking usage down by storage type, with keys such as indexedDB, caches, and serviceWorkerRegistrations. The breakdown is optional, the set of keys is not standardised, and the numbers are approximate; they are useful for finding which subsystem grew, not for accounting.

Usage is attributed to the storage key, so a third-party frame under storage partitioning is charged to its partition, not to the embedding site. This matters for embedded applications: the same widget on ten top-level sites holds ten caches and consumes ten times the space, each charged to a different key.

Where the quota number comes from

The Storage Standard deliberately does not fix a number for the whole shelf. It defines the storage quota as an implementation-defined, conservative estimate of the total bytes a storage key can hold, expects it to be less than the device’s total storage, and forbids deriving it from currently available space, because that would leak information about the device. Browsers therefore derive it from the volume’s capacity, or apply a fixed policy cap, and bound it so that one origin cannot monopolise the available space.

Two structural properties hold across implementations. First, quota is far larger than the Web Storage limit — the five-mebibyte ceiling that applies to localStorage is a separate, small allowance — and is commonly a large fraction of total disk capacity. Second, quota is shared across all the origin’s storage, so filling it with cached responses leaves nothing for the database that the application actually needs. estimate() is a snapshot with no guarantee attached; a write can still fail with QuotaExceededError immediately after a call reporting plenty of headroom, because the estimate is not a reservation and the browser’s global budget or the disk itself can fill in the meantime.

Private browsing modes use a separate, usually much smaller, in-memory budget that is discarded when the last private window closes. An application that works in a normal window can hit the quota on the first large write in a private one.

Why the reported numbers move

The estimate is produced by a quota manager that computes usage on demand, and the computation is not free, so browsers may cache the result for a short period and return a value computed before the last write. Usage figures can also lag: a transaction that has committed is charged once the browser has settled its accounting, and the breakdown may attribute bytes to a subsystem at a granularity coarser than the record. Treating the returned usage as an exact measurement of the origin’s data is wrong in both directions — it includes overhead that deletion does not immediately release, and it may omit writes still in flight.

The quota side can also change without free space moving: a different profile or device, a browser version with a different cap, or an implementation that varies the figure by engagement signals such as visit frequency produces a different number. An application that computes a target size from the reported quota at startup and then fills to that target is treating a ceiling as a capacity to plan around.

Best-effort and persistent storage

By default an origin’s storage is best-effort. The browser may evict it without asking, and the specification only requires that eviction happen under storage pressure, not that it preserve anything in particular. There is no event announcing that data is about to go, and no opportunity to flush.

navigator.storage.persist() asks for persistent storage, which exempts the origin from automatic eviction. The promise resolves with a boolean: true means the origin is now persistent, false means the request was denied and the data remains best-effort. navigator.storage.persisted() reports the current state without prompting. Whether the user is asked is up to the browser: some decide silently from heuristics such as whether the site is installed, how often it is visited, whether it is bookmarked, and whether the user has granted notifications, while others present a permission dialog. The result can differ per browser and per profile, and there is no API to ask why a request was denied.

const { usage, quota } = await navigator.storage.estimate();
const ratio = quota > 0 ? usage / quota : 0;

if (ratio > 0.8 && !(await navigator.storage.persisted())) {
  const granted = await navigator.storage.persist();
  // A false result is normal; the origin may still be evicted later.
  console.log({ granted, usage, quota });
}

Persistence is not a backup. A user clearing site data, an enterprise policy, or a profile reset removes persistent storage exactly as it removes everything else. It changes only the automatic eviction path.

How a victim is chosen

When the browser needs space, it evicts at the granularity the implementation chooses. The standard says a browser under pressure should clear network state and best-effort buckets, prioritising removals that least affect the user, and does not prescribe anything finer; implementations have generally cleared a bucket’s whole quota-managed data in one operation rather than picking individual databases, which means a cache and a user’s unsent drafts can disappear together.

Implementations have generally ordered victims by recency of use across origins, where recency is derived from the last time the origin’s storage was accessed or modified. Whether a read refreshes that position is implementation-defined, so an application should not assume that being opened regularly protects it, while a large cache belonging to a site that has not been visited in months is an early candidate. Exact recency accounting is implementation-defined, and browsers have adjusted it as they changed their quota managers; it should not be modelled precisely.

Pressure that triggers eviction is not only “disk nearly full”. A browser also enforces a browser-wide cap and, in some configurations, a per-origin cap within it, and it may clear best-effort origins when the operating system signals low disk space, when the profile is copied to a smaller device, or when the user requests space. The common case is that data disappears silently between sessions, and the application discovers it when an open request fails or a database comes back empty.

Because eviction removes the bucket rather than individual records, the page usually observes it as an absence: indexedDB.open() succeeds and recreates an empty database, firing upgradeneeded with oldVersion 0. Code that treats a successful open as evidence that the previous contents survived is wrong.

Designing against a number that moves

Three practical consequences follow. Any write path must handle QuotaExceededError and keep the unpersisted state in memory long enough to tell the user or retry after freeing space. Any read path must tolerate a store that was evicted wholesale, which usually means an application-level version marker or a “database is empty, reinitialise” path rather than a crash. And any growth policy must be bounded by the application rather than by the quota: a cache with its own size ceiling and its own eviction order behaves predictably, while one that expands until the browser objects takes the database down with it.

The estimate() call is asynchronous and usually served from a cached computation, so checking it before a large import is reasonable, but the check cannot be treated as a reservation. The only reliable pattern is to attempt the write, charge the failure path from the start, and treat the reported quota as a ceiling rather than a capacity to plan around. The Cache API article covers how cached responses consume this same budget and why opaque responses are counted more heavily.

References

  1. Storage Standard — WHATWG
  2. MDN — StorageManager — MDN Web Docs
  3. MDN — Navigator.storage — MDN Web Docs