Web Platform · Progressive Web

Storage Quota, Persistence, and Eviction

Why a cache that was written successfully can be gone on the next visit, how best-effort and persistent buckets differ, and what estimate() does and does not tell an application about its budget.

An offline strategy is a bet that the bytes written today will be readable tomorrow. The Storage Standard defines the terms of that bet, and they are less generous than the API surface suggests: storage is granted per origin from a browser-managed budget, it can be reclaimed under pressure, and the reclamation unit is the whole origin rather than individual entries. A service worker that caches a shell without considering eviction has built an offline mode that works until the device fills up.

Two kinds of bucket

The standard models an origin’s storage as a bucket. Each bucket holds the origin’s IndexedDB databases, Cache Storage entries, and origin-private file system data, and each is either best-effort or persistent. A best-effort bucket is evictable: when the browser needs space, it may delete the entire bucket without consulting the application. A persistent bucket is exempt from that automatic eviction, though it can still be cleared by the user through site-data settings, which removes it along with the rest of the origin’s storage.

Origins are placed in a best-effort bucket by default. A persistent bucket is granted through navigator.storage.persist(), which resolves to true when the browser has granted it and false when it has not. The decision is made per origin and is not a permission dialog in every browser: Chromium grants it based on heuristics such as the site being installed, having a high engagement score, or holding a notification permission, and otherwise denies without asking. Firefox asks the user. The persisted() method reports the current state without requesting a change.

const persisted = await navigator.storage.persisted();
if (!persisted) {
  const granted = await navigator.storage.persist();
  // false is a normal outcome, not an error: the origin stays best-effort.
  console.log({ granted });
}

A denial is not a failure state to retry. The heuristic is evaluated when persist() is called, and the answer can change after the user installs the application or grants notification permission, so the check belongs at application start rather than at cache-write time.

What the quota bounds

navigator.storage.estimate() resolves to an object with usage and quota, both in bytes. quota is the maximum the origin may use, derived by the browser from disk size and its own policy rather than from a fixed constant; desktop browsers commonly allow a substantial fraction of free space, while mobile browsers are far more conservative. usage is the origin’s current consumption, and on some implementations a usageDetails breakdown by storage type is also present.

Three properties limit how much the numbers can be trusted. The estimate is not a reservation: between the call and the write, another tab or another origin’s eviction can change the situation, so a write can still fail with QuotaExceededError after a successful estimate. Usage is not exactly the sum of response bodies: Cache Storage accounts for stored headers, keys, and implementation overhead, and opaque responses are padded to a coarse size that can be far larger than the payload. And quota can differ per storage key rather than per origin, because browsers that partition third-party storage group an embedded origin with its top-level site, so the same embedded origin may see different effective budgets in different embedding contexts.

The only reliable budgeting strategy is to treat the quota as an upper bound and to keep the application’s own working set well below it. A precache sized as a fraction of the quota, refreshed by version rather than accumulated, stays inside the budget even when the reported quota changes between browser versions.

Eviction is per origin, not per entry

There is no API to mark a cache entry as important, and there is no partial eviction that removes the least recently used entries first within an origin. When the browser decides to reclaim space, it clears a best-effort bucket. The service worker registration survives that; the caches it reads from do not.

The consequence is a specific failure mode. A visitor returns after a month, the origin is under pressure, and the fetch handler that normally answers from the shell cache finds nothing. If the handler treats a cache miss as “fetch from network”, the page works when online and fails when not; if it returns a constructed response for every miss, the user gets a fallback page that claims to be the requested one. Neither is a data-loss bug, but both break the promise an installed application makes. A push message can arrive in the same state, because delivery does not depend on cached content; the handler that renders the notification has to tolerate a cold cache, a case examined in Web Push: Subscription, Encryption, and Delivery.

Detecting the state is possible but not through an event. There is no eviction notification. The practical check is a sentinel entry: during activate, store a small known key, and on each start look it up. A missing sentinel means the bucket was cleared, which is the signal to re-run the precache rather than to serve a half-empty shell. Comparing estimate().usage against the expected precache size gives the same information less directly.

Filling the bucket without knowing its size

Writes fail in type-specific ways. cache.put() and cache.addAll() reject with a QuotaExceededError when the entry does not fit; an IndexedDB write triggers a transaction abort with the same error name on the request. A handler that does not catch it sees an unhandled rejection inside waitUntil, and the response to the page may still succeed while the cache write silently did not happen — the cache and the network diverged, and the next offline visit reveals it.

A cache write that fails should trigger pruning rather than a retry of the same write. The order that works is: attempt the write, catch the quota error, delete the oldest generation or the least valuable cache by name, then attempt once more. Repeating the same write without freeing space cannot succeed, and a retry loop inside a fetch handler delays the response the page is waiting for.

Browsers also apply pressure before the quota is reached. A best-effort bucket can be evicted while it is well under its quota if the device as a whole is short of space, which is why the quota number is not a safety margin. The prudent shape is a precache that is a small, bounded set of files — the shell and its critical dependencies — with everything else cached opportunistically and treated as expendable.

What survives, and what does not

An installed application is not exempt from any of this. Persistence is a property of the origin’s bucket, granted or withheld independently of installation, and an installed application with a best-effort bucket can lose its caches the same way a browser tab can. Installation affects the heuristic in some browsers; it is not a guarantee.

Private browsing is a separate regime: storage is typically memory-backed or discarded at the end of the session, quotas are small, and persist() normally resolves false. A site that detects private browsing through storage behaviour and refuses to work is relying on an unspecified difference; a site that degrades to online-only behaviour when the quota is tiny is doing the supported thing.

User-initiated clearing is immediate and total: clearing site data removes caches, databases, and the registration in one operation, and the next visit re-registers from scratch. That path is also the escape hatch when a bad deployment leaves clients with an unusable worker, which is why the update story in Service Worker Update Semantics and skipWaiting depends on the registration being replaceable rather than permanent.

For the caches themselves, the versioning discipline described in Cache Storage Keys, Vary, and Cache Versioning is what keeps the working set bounded. Two generations of a shell are affordable; two generations of everything a user has ever viewed are not, and the difference between those two states is decided when the cache names and the pruning step are designed, not when the quota is hit.

References

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