Skip to content

Storage Quotas and Persistence

Everything a PWA stores for offline use (Cache Storage, IndexedDB, the Origin Private File System, service worker registrations) comes from a per-origin quota that the browser manages. By default that data is best-effort: the browser can delete it without asking when the device runs low on space, and Safari also deletes it after seven days of browser use without interaction. This page explains the storage model defined by the Storage Standard, the exact quota rules in Chromium, Firefox and Safari, how eviction works, how to request persistent storage, how to handle QuotaExceededError, and how the Chromium-only Storage Buckets API gives you finer control.

Key takeaways

  • Quota is per storage key (the origin, plus the top-level site for third-party contexts). IndexedDB, Cache Storage, OPFS and service worker registrations share it. localStorage has its own fixed limit of about 5 MiB.
  • Chromium allows an origin up to 60% of total disk. Firefox allows the smaller of 10% of disk or 10 GiB per site group (50% of disk when persistent). Safari 17+ allows about 60% of disk in browser apps and Home Screen web apps, but about 15% in apps that embed web views.
  • Best-effort data is evicted one whole bucket at a time (for most sites that means the whole origin), least recently used first, when the device is under storage pressure. Persistent data is evicted only with the user's involvement.
  • navigator.storage.persist() is granted silently by Chromium (installed apps, high engagement, bookmarks, notification permission) and Safari (for example, Home Screen web apps), and through a prompt by Firefox.
  • Current Chrome (on by default since Chrome 148, after a staged rollout from Chrome 144) reports estimate().quota as usage + 10 GiB, not the enforced limit. Don't use quota - usage as real headroom in Chromium.
  • Each opaque (no-cors) response in Cache Storage is padded in Chromium by a pseudo-random amount averaging about 7 MiB (Firefox pads far less). Use CORS for anything you cache.
  • Safari deletes all script-writable storage after 7 days of Safari use without user interaction on the site. Home Screen web apps are exempt.

The storage model: shelves, buckets and bottles

The Storage Standard defines the architecture every browser maps its storage onto. Knowing its vocabulary makes quota and eviction behavior predictable:

flowchart TD
    Shed["Storage shed: all local storage in the browser"] --> Shelf1["Storage shelf for storage key https://app.example"]
    Shed --> Shelf2["Storage shelf for another storage key"]
    Shelf1 --> Default["Bucket 'default', mode best-effort or persistent"]
    Shelf1 --> Named["Named buckets, Storage Buckets API, Chromium"]
    Default --> B1[("caches")]
    Default --> B2[("indexedDB")]
    Default --> B3[("localStorage")]
    Default --> B4[("serviceWorkerRegistrations")]
Storage key
The partition key for all storage. For a top-level document it's effectively the origin (https://app.example). In third-party contexts, modern browsers add the top-level site, so an iframe from widget.example embedded on two different sites gets two separate storage keys, each with its own quota. Chrome shipped third-party storage partitioning in Chrome 115. Firefox and Safari partition third-party storage too. Privacy & Storage Partitioning covers the details.
Storage shelf
The container for one storage key. It holds a bucket map.
Storage bucket
The unit of persistence and eviction. Every shelf has a bucket named "default", which is what caches, indexedDB, localStorage and service worker registrations use. A local bucket's mode is "best-effort" (initial) or "persistent". The Storage Buckets API (Chromium) lets you create additional named buckets. See the Storage Buckets section.
Storage bottle
The part of a bucket used by one storage endpoint. The standard registers five endpoints: caches, indexedDB, localStorage and serviceWorkerRegistrations (local), and sessionStorage (session). It recommends a 5 MiB quota for localStorage and sessionStorage bottles and sets no per-bottle quota for the others. They share the shelf's quota.

Two rules in the standard have direct consequences for your app:

  1. A bucket is cleared in its entirety. "Whenever a storage bucket is cleared by the user agent, it must be cleared in its entirety." You never lose half your IndexedDB database while Cache Storage survives. An eviction takes everything in the bucket together: your precache, your offline data, your service worker registration. The standard adds that user agents "should avoid clearing storage buckets while script that is able to access them is running", which is why WebKit skips origins with an open page and why evictions in practice happen between visits.
  2. Quota must not reveal free space. "The storage quota of a storage shelf is an implementation-defined conservative estimate... It must not be a function of the available storage space on the device." That's why browsers compute quotas from total disk size (and, in Chromium, now report a fixed value), and why your origin may run out of real space before it reaches its nominal quota.

What counts toward the quota

Storage Counts toward the origin's quota Notes
IndexedDB ✅ Includes indexes and stored Blobs
Cache Storage ✅ Includes response bodies and headers. Opaque responses are padded (see below)
Origin Private File System ✅ Chromium reports it under usageDetails.fileSystem. See Origin Private File System
Service worker registrations ✅ Script resources for registered workers. Chromium reports them as usageDetails.serviceWorkerRegistrations
localStorage, sessionStorage Separate limit About 5 MiB each per origin in all major browsers. setItem() throws QuotaExceededError when exceeded
Cookies Separate limit Per-cookie and per-domain limits defined by the browser
HTTP cache ❌ Managed and evicted by the browser independently. See HTTP Caching & Service Workers

Best-effort vs persistent storage

Every local bucket starts in best-effort mode. The browser may delete it without telling the user or your app when it needs space, following the rules in Eviction. A bucket in persistent mode is protected: the standard says "the user agent cannot clear storage marked as persistent without involvement from the origin or user." Under continued storage pressure, the browser should ask the user rather than silently deleting persistent data.

Persistence is a permission, "persistent-storage". The standard defines it as a powerful feature whose state is the same for every environment of an origin. Revoking it switches the default bucket back to best-effort. How the permission is granted differs a lot between browsers:

Browser How persist() is decided UI shown
Chromium (Chrome, Edge, Samsung Internet, Opera) Automatic heuristics (see below) None
Firefox Asks the user Permission prompt
Safari 17+ Automatic heuristics, "like whether the website is opened as a Home Screen Web App" (WebKit) None

There is no API to give persistence up. A site that no longer needs it can't return to best-effort from script. Only the user can, through site settings.

Measuring usage with navigator.storage.estimate()

Storage Standard: StorageManager (IDL)
[SecureContext, Exposed=(Window,Worker)]
interface StorageManager {
  Promise<boolean> persisted();
  [Exposed=Window] Promise<boolean> persist();
  Promise<StorageEstimate> estimate();
};

dictionary StorageEstimate {
  unsigned long long usage;
  unsigned long long quota;
  StorageUsageDetails usageDetails; // Chromium only, non-standard
};

navigator.storage exists only in secure contexts. estimate() and persisted() are available in windows and all workers, including the service worker. persist() is window-only, because it may need to show UI. All three reject with a TypeError if the environment has no storage shelf, for example in an opaque-origin sandboxed iframe or when the user has blocked storage for the site.

What the numbers mean:

  • usage is "an implementation-defined rough estimate of the amount of bytes used". Browsers are encouraged to hide exact sizes through deduplication and compression, and they add padding for opaque responses. Treat it as an order of magnitude, not an exact byte count.
  • quota is "a conservative estimate of the total amount of bytes it can hold". In practice its meaning differs by engine:
    • Current Chromium reports a predictable value instead of the enforced quota: usage + 10 GiB on any device with at least 10 GiB of disk (on smaller disks, usage plus the disk size rounded up to the next GiB). Chromium made this change to stop estimate() from leaking disk size (a fingerprinting vector) and revealing Incognito mode. The history is messy: the code landed behind a flag in October 2024, the ChromeStatus entry originally announced it for Chrome 138, it reached users through a server-side rollout starting in Chrome 144, and it became the built-in default (StaticStorageQuota) in Chrome 148, which is the shipping milestone ChromeStatus lists. The enforced quota didn't change. Incognito computed the reported value from its RAM-sized pool, which still gave it away, until a follow-up feature (IncognitoStaticStorageQuota) made Incognito report usage + 10 GiB too. That feature ran as a field trial first. Chromium switched it on by default in late August 2026, so it's the built-in default from Chrome 154, which hadn't reached the stable channel in September 2026. Sites with unlimited-storage permission (such as extensions) still see the real quota, and storage buckets opened with an explicit quota report that requested value.
    • Firefox and Safari 17+ report a quota derived from their own limits, described below. Safari didn't expose estimate() before Safari 17.
  • usageDetails (Chromium 61+) breaks usage down by endpoint, with the keys indexedDB, caches, serviceWorkerRegistrations and fileSystem. Treat a missing key as zero.
storage-estimate.js
/**
 * Returns a normalized snapshot of this origin's storage situation.
 * Safe in windows, workers and the service worker.
 */
export async function getStorageSnapshot() {
  if (!navigator.storage?.estimate) {
    return { supported: false };
  }

  let estimate;
  try {
    estimate = await navigator.storage.estimate();
  } catch (error) {
    // TypeError: no storage shelf (opaque origin, storage blocked by the user).
    return { supported: true, available: false, error: error.name };
  }

  const { usage = 0, quota = 0, usageDetails = {} } = estimate;
  const persisted = navigator.storage.persisted
    ? await navigator.storage.persisted().catch(() => false)
    : false;

  return {
    supported: true,
    available: true,
    usage,
    quota,
    // In current Chromium this ratio is usage / (usage + 10 GiB): useful as a trend,
    // useless as a warning threshold. Pair it with your own budget (below).
    usageRatio: quota ? usage / quota : null,
    breakdown: {
      indexedDB: usageDetails.indexedDB ?? null,
      caches: usageDetails.caches ?? null,
      serviceWorkerRegistrations: usageDetails.serviceWorkerRegistrations ?? null,
      fileSystem: usageDetails.fileSystem ?? null,
    },
    persisted,
  };
}

export function formatBytes(bytes) {
  if (bytes == null) return "n/a";
  const units = ["B", "KiB", "MiB", "GiB", "TiB"];
  let value = bytes;
  let unit = 0;
  while (value >= 1024 && unit < units.length - 1) {
    value /= 1024;
    unit += 1;
  }
  return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
}

Because the reported quota is no longer a reliable ceiling in the most widely used engine, define your own storage budget (for example, "runtime caches stay under 200 MiB") and enforce it with cache expiration. Use usage to verify that the budget holds.

Requesting persistence: persist() and persisted()

persist() runs the permission request for "persistent-storage" and resolves true if the default bucket ends up persistent. It resolves false if the permission was refused, or if an internal error occurred. It doesn't reject on denial. persisted() reports the current mode without requesting anything.

How Chromium decides

Chromium's PersistentStoragePermissionContext implements the decision without any UI. In the current source, the steps are:

  1. Only top-level documents can be granted. A request from an iframe whose origin differs from the top-level origin resolves false.
  2. Cookies must be fully allowed and not session-only for the origin. If the user blocks cookies for the site, or has it set to "clear on exit", persistence would be meaningless and is refused.
  3. Installed apps are granted. If the site's registrable domain belongs to an installed web app, the request is granted and remembered. The comparison is by registrable domain, not origin, so installing app.example.com also lets docs.example.com get persistence.
  4. "Important sites" are granted. Chromium computes up to 10 important registrable domains from site engagement (at least "medium" engagement), bookmarks (up to 5), home screen shortcuts and notification permission. If the site is among them, the request is granted and remembered.
  5. Everything else is refused, but not remembered. The denial isn't stored, so the same site can call persist() again later, after the user installs the app or engages more, and succeed.

In practice, the most reliable way to get persistent storage in Chromium is to ask after installation (for example, in the appinstalled handler, or at the first launch in standalone mode), or after the user grants notification permission. Asking on first page load almost always returns false.

How Firefox and Safari decide

Firefox shows a permission prompt asking the user whether the site may store data in persistent storage. The user's answer is remembered as a site permission. Because a prompt is disruptive, call persist() only in response to a clear user intent, such as a "Keep available offline" button.

Safari 17 and later decides with heuristics and shows no prompt. WebKit names being opened as a Home Screen web app as one of them. Its storage policy post advises error handling regardless, because quotas are upper limits, not guarantees.

Checking the permission state without prompting

Chromium (71+) and Firefox (53+) expose the permission through the Permissions API. Safari doesn't support the "persistent-storage" name in permissions.query():

persistence.js
/**
 * Request persistent storage at a moment when it is likely to succeed and
 * unlikely to annoy: after install, or after an explicit user action.
 * Returns "persistent", "best-effort" or "unsupported".
 */
export async function ensurePersistentStorage({ userInitiated = false } = {}) {
  if (!navigator.storage?.persist) return "unsupported";

  if (await navigator.storage.persisted()) return "persistent";

  // Avoid triggering Firefox's prompt unless the user asked for offline access.
  let state = "prompt";
  try {
    const status = await navigator.permissions.query({ name: "persistent-storage" });
    state = status.state; // "granted" | "denied" | "prompt"
  } catch {
    // Safari (and older browsers) reject unknown permission names: fall through.
  }

  if (state === "denied") return "best-effort";

  const isStandalone =
    matchMedia("(display-mode: standalone)").matches ||
    navigator.standalone === true; // iOS/iPadOS Home Screen and macOS Dock web apps

  // Chromium and Safari grant silently, so asking in these moments is free.
  // Firefox shows a prompt, so only ask there when the user initiated it.
  if (state === "granted" || userInitiated || isStandalone) {
    try {
      return (await navigator.storage.persist()) ? "persistent" : "best-effort";
    } catch {
      return "best-effort";
    }
  }
  return "best-effort";
}

// Ask right after installation: in Chromium, installed apps are always granted.
window.addEventListener("appinstalled", () => {
  ensurePersistentStorage().then((mode) => console.info("Storage mode:", mode));
});

There's no reliable way to tell from script whether a prompt state will turn into a silent heuristic decision (Chromium, Safari) or a visible prompt (Firefox). The code above therefore requests silently only in contexts where success is likely, and leaves the explicit user action as the trigger elsewhere. Installation by Platform explains how to detect standalone launches on each platform.

How much can you store: per-browser quota rules

Support data as of September 2026. The figures come from the engines' own documentation and source code, and they change. Check MDN's storage quotas page for the current state.

Engine and context Per-origin quota (best-effort) Per-origin quota (persistent) Browser-wide limit
Chromium, regular profile 60% of total disk 60% of total disk 80% of total disk
Chromium, Incognito ⅓ of an in-memory pool sized 15–20% of physical RAM (randomized) n/a Pool, in memory
Chromium, site set to "clear on exit" min(≈300 MB, 10% of the normal quota) n/a —
Firefox min(10% of disk, 10 GiB shared by the eTLD+1 group) 50% of disk, max 8 TiB, no group limit —
Safari / WebKit browser apps (macOS 14, iOS 17+) ≈60% of total disk ≈60% of total disk ≈80% of total disk
Home Screen / Dock web apps (WebKit) Same as browser apps Same as browser apps Same as browser apps
Other apps embedding WebKit (WKWebView) ≈15% of total disk ≈15% of total disk ≈20% of total disk
Cross-origin iframes in WebKit 10% of the main frame origin's quota — —
Safari 16 and earlier 1 GiB initially, then prompts the user — —

Worked numbers for common devices

The rules above turn into very different absolute numbers depending on the device. The table applies them to typical disk sizes. "Disk" means the total capacity the operating system reports, which is somewhat less than the marketed capacity, and all figures are nominal ceilings. The real limit on a nearly full device is its free space.

Total disk Chromium per-origin quota (60%) Chromium evicts for disk pressure when free space drops below Firefox best-effort (min of 10% and 10 GiB) Firefox persistent (50%) Safari browser app (≈60%) App embedding WKWebView (≈15%)
16 GB ≈9.6 GB ≈1.6 GB (10%) ≈1.6 GB ≈8 GB ≈9.6 GB ≈2.4 GB
64 GB ≈38 GB 2 GiB ≈6.4 GB ≈32 GB ≈38 GB ≈9.6 GB
128 GB ≈77 GB 2 GiB 10 GiB (group limit) ≈64 GB ≈77 GB ≈19 GB
512 GB ≈307 GB 2 GiB 10 GiB (group limit) ≈256 GB ≈307 GB ≈77 GB
1 TB ≈600 GB 2 GiB 10 GiB (group limit) ≈500 GB ≈600 GB ≈150 GB

Two conclusions follow. First, on anything but the smallest phones, quota is rarely the constraint for a PWA: free disk space, eviction and Safari's 7-day rule are. Second, Firefox's 10 GiB group limit is the tightest per-site ceiling on large disks, and it's shared by every origin under the same registrable domain, so app.example.com and cdn.example.com compete for it.

Chromium in detail

The numbers come from storage/browser/quota in the Chromium source:

  • Pool size is 80% of the total disk (kPoolSizeRatio = 0.8). Every origin's best-effort and persistent data counts against it.
  • Per-storage-key quota is 75% of the pool (kDefaultPerStorageKeyRatio = 0.75), which is 60% of total disk. On a 256 GB phone that's about 153 GB for one origin. Persistence doesn't change the number. It changes whether the data can be evicted.
  • Reserved free space. Chromium tries to keep min(2 GiB, 10% of disk) free (should_remain_available). A background evictor runs an eviction round every 30 minutes (kEvictionInterval). Each round first deletes expired storage buckets, then computes how much more to delete: the larger of (a) the amount by which free disk space has fallen below should_remain_available and (b) the amount by which total best-effort usage exceeds 70% of the pool (kUsageRatioToStartEviction = 0.7, which is 56% of the disk). It then deletes whole least-recently-used best-effort buckets until it has freed that much. If all site data together is less than half the free-space shortage, it doesn't evict for disk pressure at all, because deleting everything wouldn't help. A second threshold, min(1 GiB, 1% of disk) (must_remain_available), only constrains origins with unlimited storage, such as extensions. On a 16 GB device, eviction for disk pressure starts when free space drops below 1.6 GB.
  • The nominal quota assumes free disk that may not exist. Quotas are computed from total disk size, not free space, so on a nearly full phone a write fails because the disk is full long before the origin reaches "60% of disk". Expect QuotaExceededError (or, for some I/O failures, UnknownError) on low-end Android devices at usage levels far below the nominal quota.
  • Incognito keeps storage in memory. The pool is a randomized 15–20% of physical RAM, and each storage key gets a third of it. A device with 8 GiB of RAM gives an origin roughly 410–550 MiB in Incognito.
  • Session-only origins (cookies set to clear when the browser closes) get the smaller of about 300 MiB (randomized by ±10%) and 10% of the normal per-key quota.
  • Reported quota is usage + 10 GiB in current Chrome, as described above. The limits in this list are what's enforced.

Firefox in detail

Firefox applies two limits to best-effort storage: 10% of the disk holding the profile, and a group limit of 10 GiB shared by all origins with the same eTLD+1. https://app.example.com and https://cdn.example.com share one 10 GiB group. MDN gives an example: on a 500 GiB disk, the best-effort limit is 10 GiB (the group limit), while persistent storage may use up to 250 GiB (50%). Persistent storage isn't subject to the group limit and is capped at 8 TiB.

In private browsing, Firefox enabled IndexedDB in Firefox 115 and the Cache API in Firefox 122, both backed by encrypted on-disk storage. Service workers followed in Firefox 140 (they were briefly enabled for Firefox 139, then held back a release for more testing). Everything a private window stores is discarded when the private session ends. Before these releases, a PWA opened in a Firefox private window got no service worker, and its IndexedDB or Cache Storage calls failed, so older bug reports about "offline mode not working in private browsing" usually describe that gap.

Safari and WebKit in detail

WebKit's August 2023 storage policy update (Safari 17, macOS 14 and iOS 17) replaced the old 1 GiB-plus-prompt model:

  • Browser apps (Safari, and other browsers built on WebKit) allow an origin up to 60% of total disk, and all origins together up to 80%.
  • Other apps that display web content through WKWebView allow 15% per origin and 20% overall. In-app browsers built this way give your PWA much less room than Safari does.
  • Home Screen web apps on iOS and iPadOS, and web apps added to the Dock on macOS, get the browser-app quotas.
  • Cross-origin frames get 10% of the main frame origin's quota, to limit tracking through embedded storage.
  • On iOS and iPadOS, every browser app uses WebKit (outside the EU's alternative-engine regime), so these rules apply to Chrome, Firefox and Edge on iPhone too. iOS & iPadOS covers platform specifics.

Eviction: how and when browsers delete data

Browsers delete site data in four situations.

Storage pressure

When the device runs low on space, browsers delete best-effort buckets, least recently used origin first, until enough space is free. Persistent buckets are skipped. The Storage Standard adds that if pressure continues, the browser should inform the user and offer to clear persistent data. It shouldn't delete it silently.

  • Chromium evicts in rounds (every 30 minutes) once free space falls below its should_remain_available threshold, and uses LRU by bucket. For the default bucket that's the same as LRU by origin.
  • Firefox uses LRU by origin.
  • WebKit orders origins by last use, meaning the last user interaction or the last storage operation, whichever is later. It skips origins that have an active page at the time and origins in persistent mode.

Browser-wide limit exceeded

When the combined usage of all origins grows past the browser's total allowance, the same LRU eviction of best-effort data runs, even with free disk space left. MDN and WebKit describe the allowance as 80% of disk for Chromium and WebKit browser apps. Chromium's evictor actually starts earlier: it trims best-effort usage back once it exceeds 70% of that 80% pool, which is 56% of the disk.

Safari's proactive eviction (the 7-day cap)

Safari runs time-based deletion in addition to pressure-based eviction. It's covered in its own section below because it affects PWAs more than any other rule.

User action and server instructions

Users can clear site data from browser settings. That clears persistent buckets too. Your server can clear the origin's data with Clear-Site-Data: "storage", which also unregisters service workers. HTTP Caching & Service Workers covers the header.

No event tells you that eviction happened

No API fires when a bucket is evicted. The first sign is an empty database or a missing cache at the next start. Design for it:

  • Keep the server as the source of truth for anything the user can't afford to lose, and re-sync after a wipe. Offline-First Data & Sync covers sync design.
  • Detect wipes explicitly, so you can re-download essentials and measure how often it happens:
eviction-detector.js
const DB_NAME = "app-meta";
const STORE = "meta";

function openMetaDb() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, 1);
    request.onupgradeneeded = () => request.result.createObjectStore(STORE);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
    request.onblocked = () => reject(new Error("meta DB upgrade blocked by another tab"));
  });
}

function idbRequest(request) {
  return new Promise((resolve, reject) => {
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

/**
 * Call at startup. `hasServerSession` should reflect a server-issued session
 * (for example, a successful /api/me call). Server-set cookies aren't
 * deleted by Safari's 7-day cap, so a live session with no local marker means
 * local storage was evicted or cleared since the last visit.
 */
export async function detectStorageWipe({ hasServerSession }) {
  const db = await openMetaDb();
  try {
    const tx = db.transaction(STORE, "readwrite");
    const store = tx.objectStore(STORE);
    const marker = await idbRequest(store.get("install-marker"));
    if (!marker) {
      await idbRequest(store.put({ createdAt: Date.now() }, "install-marker"));
    }
    await new Promise((resolve, reject) => {
      tx.oncomplete = resolve;
      tx.onabort = () => reject(tx.error);
    });
    return { wiped: !marker && hasServerSession, firstRun: !marker && !hasServerSession };
  } finally {
    db.close();
  }
}

Safari's 7-day cap on script-writable storage

In March 2020, with iOS and iPadOS 13.4 and Safari 13.1 on macOS, WebKit's Intelligent Tracking Prevention extended its cookie rules to all script-writable storage (Full Third-Party Cookie Blocking and More). The rule, as stated on WebKit's Tracking Prevention page: ITP "deletes all cookies created in JavaScript and all other script-writeable storage after 7 days of no user interaction with the website."

What that covers and how it's measured:

  • Affected storage: IndexedDB, localStorage, sessionStorage, media keys, and service worker registrations and Cache Storage. Cookies set by the server in HTTP responses aren't script-writable and aren't deleted by this rule (separate rules cap some server-set cookies, such as those set through CNAME cloaking).
  • Scope: all websites, not only domains classified as trackers, whenever tracking prevention is on (the default).
  • Clock: seven days of Safari use, not calendar days. The 2020 announcement specifies days on which the browser is actually used. A user who doesn't open Safari for two weeks doesn't lose data because of the calendar alone.
  • Reset: user interaction with the site as a first party (a tap, click or key press, not just a page load) resets the counter.
  • Don't count on persist() to exempt you. WebKit's tracking-prevention documentation lists only Home Screen web apps as exempt, and says nothing about the "persistent-storage" permission. WebKit's source does skip a set of "persisted" domains, but that list is supplied by the embedding app, not by navigator.storage.persist(), so treat a browser-tab origin as subject to the cap even when persisted() resolves true.

WebKit's source keeps the exemption list for this removal algorithm explicit (domainsExemptFromWebsiteDataDeletion()): app-bound domains, managed domains, domains the embedder marks as persisted, and the domain of the standalone (Home Screen) application.

Home Screen web apps are exempt. WebKit states: "The first-party domain of home screen web applications is exempt from ITP's 7-day cap on all script-writeable storage, i.e. ITP always skips that domain in its website data removal algorithm." The 2020 announcement explains that Home Screen web apps "are not part of Safari and thus have their own counter of days of use". Their storage is also separate from Safari's for the same origin. An installed app doesn't share IndexedDB or Cache Storage with the same site open in a Safari tab.

What this means for your PWA on Apple platforms:

  1. Assume browser-tab users on Safari can lose all offline data after a week of not interacting with your site, including the service worker registration itself. The next visit is a first visit.
  2. Encourage installation on iOS and iPadOS for use cases that depend on offline data. See Installation by Platform and Install Prompts & Custom UI.
  3. Never keep the only copy of user data in browser storage. Sync drafts and outbox items to the server as soon as you can (see Background Sync for Chromium, and sync-on-open everywhere else).

Opaque responses and quota padding

An opaque response is what fetch() returns for a cross-origin request made with mode: "no-cors": status 0, no readable headers, no readable body. That's also what a service worker receives when it forwards a request from <img> or <script> without a crossorigin attribute to a server that doesn't support CORS. Cache Storage accepts opaque responses, but storing them has a quota cost you can't see from the response.

If quota usage reflected an opaque response's real size, a page could measure cross-origin resources: fill the quota until writes fail, store an opaque response, and see whether it fits. Browsers prevent this by padding opaque responses' contribution to usage:

  • Chromium computes padding in storage/common/quota/padding_key.cc. Each opaque response (and opaque redirect) gets a padding between 0 and 14,431 KiB, about 14.1 MiB. The padding is derived from an HMAC over the response URL, response time, site, request method and side-data size, keyed with a secret generated once per browser session. The padding is added on top of the response's real size, so the average extra cost of an opaque response is about 7 MiB (half the range, 7,215.5 KiB), however small the response is. Because the key includes the response time, re-storing the same URL later gets a different padding. Older write-ups describe a fixed "7 MB minimum". The current implementation is pseudo-random with that average.
  • Firefox also pads opaque responses in Cache Storage (Mozilla bug 1290481, shipped in Firefox 57), but far less. Each opaque response gets a random value between 0 and 1 MiB (kMaxRandomNumber = 1048576 in InternalResponse.cpp). The body size plus that value is then rounded up to the next multiple of 128 KiB (kRoundUpNumber = 131072 in dom/cache/FileUtils.cpp), and the difference is recorded as padding. An opaque response therefore costs its real size plus roughly 0.5 MiB on average in Firefox, compared with about 7 MiB in Chromium.

The arithmetic is brutal. Caching 100 small third-party images as opaque responses adds about 700 MiB to your origin's usage in Chromium. In Incognito, with a few hundred megabytes of quota, a handful of opaque responses can exhaust the quota.

How to avoid it:

  • Request cross-origin resources with CORS. Add crossorigin="anonymous" to <img>, <script> and <link> tags for CDN-hosted assets, and make sure the CDN sends Access-Control-Allow-Origin. The response is then a cors response with a real size.
  • Upgrade no-cors requests in the worker, when you know the server supports CORS:
sw.js (runtime caching for a CORS-enabled image CDN)
const IMAGE_CACHE = "images-v2";
const IMAGE_CDN = "https://images.example-cdn.com";

self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);
  if (url.origin !== IMAGE_CDN || event.request.destination !== "image") return;

  event.respondWith(
    (async () => {
      const cache = await caches.open(IMAGE_CACHE);
      const cached = await cache.match(url.href);
      if (cached) return cached;

      // Re-issue as a CORS request so the response is readable, sized honestly,
      // and not padded. Requires Access-Control-Allow-Origin on the CDN.
      const response = await fetch(url.href, { mode: "cors", credentials: "omit" });
      if (response.ok && response.type === "cors") {
        await cache.put(url.href, response.clone()).catch(() => {
          // Quota exceeded: serve the response anyway and let expiration catch up.
        });
      }
      return response;
    })(),
  );
});

A CORS response can be used by an <img> that made a no-cors request. The reverse isn't true: an opaque response can't satisfy a request whose mode is cors, so upgrading in the worker is safe.

  • If you must cache opaque responses, cap them hard. In Workbox, add status 0 to CacheableResponsePlugin only for the specific route, and pair it with ExpirationPlugin({ maxEntries: 20, purgeOnQuotaError: true }). See Advanced Workbox.
  • Never precache opaque responses. cache.add() and addAll() reject responses that aren't ok, and an opaque response's status is 0, so they fail anyway. Code that works around this with put() should be treated as a bug.

Handling QuotaExceededError

Writes that exceed the quota fail with an error named "QuotaExceededError", but each API surfaces it differently:

API How the quota error surfaces
Cache Storage cache.put(), add() and addAll() reject. The spec's Batch Cache Operations algorithm rolls back every change in the batch, so a failed addAll() leaves the cache exactly as it was
IndexedDB The transaction aborts: an abort event fires and transaction.error.name === "QuotaExceededError". The failure often appears at commit time, not on the individual put() request
OPFS FileSystemWritableFileStream.write() / close() reject; FileSystemSyncAccessHandle.write() throws
localStorage / sessionStorage setItem() throws synchronously

The QuotaExceededError interface (Chrome 138+)

Historically the error was a plain DOMException with name === "QuotaExceededError" and the legacy code 22. Web IDL has since made QuotaExceededError its own class that extends DOMException, the first "DOMException derived interface" the standard predefines:

Web IDL: QuotaExceededError
[Exposed=*, Serializable]
interface QuotaExceededError : DOMException {
  constructor(optional DOMString message = "", optional QuotaExceededErrorOptions options = {});

  readonly attribute double? quota;
  readonly attribute double? requested;
};

dictionary QuotaExceededErrorOptions {
  double quota;
  double requested;
};

The details that matter in practice:

  • name is still "QuotaExceededError", and the inherited code getter still returns 22 for this class (unlike other derived interfaces, whose code is 0). Web IDL discourages relying on code either way.
  • quota and requested are nullable numbers. The constructor throws a RangeError if either is negative, or if both are present and requested is less than quota.
  • It's serializable. quota and requested survive postMessage() and structuredClone(), so a worker can forward the full error to a page.
  • Support. Chrome 138 shipped it. At the time of writing, MDN shows it in Safari Technology Preview and not in Firefox. The Chrome launch entry says existing specs throw the new class with both properties left null for now, and may populate them later where that's useful and not a privacy leak.

Code that checks error.name === "QuotaExceededError" keeps working in every browser, and error instanceof DOMException is still true. Don't rely on quota or requested being non-null.

A robust quota error handler

quota.js
/** True for quota errors from any storage API in any engine. */
export function isQuotaExceeded(error) {
  if (!error) return false;
  if (typeof QuotaExceededError === "function" && error instanceof QuotaExceededError) {
    return true; // Chrome 138+
  }
  return (
    error.name === "QuotaExceededError" ||
    error.code === 22 ||
    // Legacy Firefox localStorage errors.
    error.name === "NS_ERROR_DOM_QUOTA_REACHED" ||
    error.code === 1014
  );
}

/** Wrap an IndexedDB transaction so quota aborts become rejected promises. */
export function transactionDone(tx) {
  return new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onabort = () => reject(tx.error ?? new DOMException("Transaction aborted", "AbortError"));
  });
}

/**
 * Run a write. On a quota error, free space with `reclaim()` and retry once.
 * Returns true if the write eventually succeeded.
 */
export async function withQuotaRecovery(write, { reclaim, onGiveUp } = {}) {
  try {
    await write();
    return true;
  } catch (error) {
    if (!isQuotaExceeded(error)) throw error;
  }

  if (reclaim) {
    try {
      await reclaim();
      await write();
      return true;
    } catch (error) {
      if (!isQuotaExceeded(error)) throw error;
    }
  }

  onGiveUp?.();
  return false;
}

reclaim() should delete data you can re-create, in order of least value: old runtime caches first, then expired API responses, then media the user can download again. Never delete unsynced user data to make room.

reclaim.js
const DISPOSABLE_CACHE_PREFIXES = ["images-", "api-", "runtime-"];

/** Delete disposable caches, then trim the oldest entries of what's left. */
export async function reclaimSpace({ keepPerCache = 50 } = {}) {
  const names = await caches.keys();
  const disposable = names.filter((name) =>
    DISPOSABLE_CACHE_PREFIXES.some((prefix) => name.startsWith(prefix)),
  );

  // Deleting a whole cache is the cheapest way to free space.
  for (const name of disposable.slice(0, -1)) {
    await caches.delete(name);
  }

  // Trim the newest disposable cache. keys() returns entries in write order
  // (put() moves an entry to the end), so the first ones are the least
  // recently written.
  const last = disposable.at(-1);
  if (last) {
    const cache = await caches.open(last);
    const requests = await cache.keys();
    const excess = requests.length - keepPerCache;
    for (const request of requests.slice(0, Math.max(0, excess))) {
      await cache.delete(request);
    }
  }
}

Usage together:

save-article.js
import { withQuotaRecovery } from "./quota.js";
import { reclaimSpace } from "./reclaim.js";

export async function saveArticleForOffline(url) {
  const response = await fetch(url, { cache: "no-cache" });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);

  const cache = await caches.open("saved-articles");
  const saved = await withQuotaRecovery(() => cache.put(url, response.clone()), {
    reclaim: () => reclaimSpace({ keepPerCache: 20 }),
    onGiveUp: () =>
      document.dispatchEvent(
        new CustomEvent("app:toast", {
          detail: "Your device is low on storage. This article wasn't saved for offline use.",
        }),
      ),
  });
  return saved;
}

Storage errors aren't always about quota. IndexedDB can also fail with UnknownError (disk I/O problems, profile corruption) or InvalidStateError. Report those separately, because freeing space won't fix them.

Storage Buckets API (Chromium)

Chromium-only

The Storage Buckets API shipped in Chrome 122 and is available in Chromium-based browsers (Edge, Opera, Samsung Internet). Firefox and Safari don't implement it. MDN still marks it experimental. The durability option and durability() method, and the locks attribute, are behind experimental flags in Chromium (StorageBucketsDurability, StorageBucketsLocks) and aren't available by default. Always feature-detect and fall back to the default bucket.

The default bucket is all-or-nothing: an eviction deletes your precache, your cached API data and the user's unsynced drafts together. Storage Buckets lets an origin create named buckets, each with its own persistence mode, optional quota and optional expiration. The browser can evict each best-effort bucket independently, so disposable data can go first while valuable data is persisted.

API surface

Storage Buckets API (IDL)
[SecureContext]
interface mixin NavigatorStorageBuckets {
  [SameObject] readonly attribute StorageBucketManager storageBuckets;
};
Navigator includes NavigatorStorageBuckets;
WorkerNavigator includes NavigatorStorageBuckets;

[Exposed=(Window,Worker), SecureContext]
interface StorageBucketManager {
  Promise<StorageBucket> open(DOMString name, optional StorageBucketOptions options = {});
  Promise<sequence<DOMString>> keys();
  Promise<undefined> delete(DOMString name);
};

dictionary StorageBucketOptions {
  boolean persisted = false;
  unsigned long long quota;
  DOMHighResTimeStamp expires;
};

[Exposed=(Window,Worker), SecureContext]
interface StorageBucket {
  readonly attribute DOMString name;
  [Exposed=Window] Promise<boolean> persist();
  Promise<boolean> persisted();
  Promise<StorageEstimate> estimate();
  Promise<undefined> setExpires(DOMHighResTimeStamp expires);
  Promise<DOMHighResTimeStamp?> expires();
  [SameObject] readonly attribute IDBFactory indexedDB;
  [SameObject] readonly attribute CacheStorage caches;
  Promise<FileSystemDirectoryHandle> getDirectory();
};

Semantics you need to know:

  • Names may contain only lowercase ASCII letters, digits, _ and -. They can't start with _ or -, and they must be 1–64 characters long. An invalid name rejects with a TypeError (the spec asks delete() to reject with InvalidCharacterError instead, but Chromium uses TypeError for both). The restriction avoids file system and HTTP-header escaping problems.
  • open() creates or reuses. If a bucket with that name exists and hasn't expired, it's returned. An expired bucket is treated as missing and replaced by a new, empty one.
  • Which options apply to an existing bucket. quota is fixed when the bucket is created. In Chromium, a later open() still updates the expiration (when you pass a different expires) and upgrades the bucket to persistent (when you pass persisted: true and the permission is granted). It never downgrades a persistent bucket.
  • persisted: true requests the same "persistent-storage" permission as navigator.storage.persist(), with the same browser heuristics. If the permission isn't granted, open() still resolves, with a best-effort bucket. Always check await bucket.persisted() afterwards. bucket.persist() is window-only.
  • quota caps the bucket's usage. Writes beyond it fail with QuotaExceededError. A quota of 0 rejects open() with a TypeError, and the IDL uses [EnforceRange] in Chromium, so negative or non-finite values throw too. The spec says a quota larger than what's available to the site is ignored. In Chromium, the effective limit is the smaller of the requested quota and the site's quota, and bucket.estimate().quota reports the requested value when you set one, and usage + 10 GiB otherwise.
  • expires is a timestamp in milliseconds since the epoch. A timestamp in the past rejects open() with a TypeError. Expired buckets are removed the next time open() or keys() looks at them, and Chromium's evictor also deletes expired buckets at the start of every eviction round (every 30 minutes). Best-effort buckets may be evicted earlier under pressure. setExpires() and expires() update and read the value. Calling a method on a StorageBucket whose bucket was removed rejects with InvalidStateError.
  • Bucket count. Chromium caps the number of buckets per storage key at max(1, quota ÷ 20 MiB). With a 60%-of-disk quota that's thousands, but a script that creates a bucket per document or per user can hit it. The failure is a QuotaExceededError with the message "Too many buckets created."
  • Contexts without storage. In an opaque-origin context (a sandboxed iframe without allow-same-origin, for example), Chromium rejects open(), keys() and delete() with a SecurityError.
  • Isolation. Each bucket has its own indexedDB factory, CacheStorage and OPFS root. A database named "app" in bucket "drafts" is a different database from "app" in the default bucket. The global indexedDB and caches always refer to the default bucket, including in the service worker. Persistence is per bucket too: navigator.storage.persist() protects only the default bucket (Chromium's eviction code says so explicitly), so every named bucket that holds valuable data needs its own persisted: true or bucket.persist().
  • Workers. navigator.storageBuckets is available in dedicated, shared and service workers, so a service worker can read and write bucket caches.

Example: separating drafts, media and API caches

buckets.js
const hasBuckets = "storageBuckets" in navigator;
const DAY = 24 * 60 * 60 * 1000;

/**
 * Open the storage areas this app uses. Without Storage Buckets support,
 * everything falls back to the default bucket (the global indexedDB/caches).
 */
export async function openStorageAreas() {
  if (!hasBuckets) {
    return {
      drafts: { indexedDB, persisted: await navigator.storage?.persisted?.() ?? false },
      media: { caches },
      api: { caches },
      bucketed: false,
    };
  }

  const [drafts, media, api] = await Promise.all([
    // Unsynced user content: ask for persistence (granted per browser heuristics).
    navigator.storageBuckets.open("drafts", { persisted: true }),
    // Re-downloadable media: capped at 500 MiB, evicted first under pressure.
    navigator.storageBuckets.open("media", { quota: 500 * 1024 * 1024 }),
    // API responses: expire automatically after two weeks.
    navigator.storageBuckets.open("api-cache", { expires: Date.now() + 14 * DAY }),
  ]);

  return {
    drafts: { indexedDB: drafts.indexedDB, persisted: await drafts.persisted() },
    media: { caches: media.caches, bucket: media },
    api: { caches: api.caches, bucket: api },
    bucketed: true,
  };
}

/** Open (or upgrade) the drafts database inside whichever factory we got. */
export function openDraftsDb(factory) {
  return new Promise((resolve, reject) => {
    const request = factory.open("drafts", 1);
    request.onupgradeneeded = () => {
      request.result.createObjectStore("drafts", { keyPath: "id" });
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

/** Report per-bucket usage, for diagnostics or telemetry. */
export async function describeBuckets() {
  if (!hasBuckets) return [];
  const names = await navigator.storageBuckets.keys();
  return Promise.all(
    names.map(async (name) => {
      const bucket = await navigator.storageBuckets.open(name);
      const { usage, quota } = await bucket.estimate();
      return { name, usage, quota, persisted: await bucket.persisted(), expires: await bucket.expires() };
    }),
  );
}

And the service worker side, serving media from the media bucket:

sw.js (reading from a named bucket)
let mediaCachePromise;

function getMediaCache() {
  // Open once per worker lifetime. Fall back to the default bucket elsewhere.
  mediaCachePromise ??= ("storageBuckets" in self.navigator
    ? self.navigator.storageBuckets.open("media", { quota: 500 * 1024 * 1024 }).then((b) => b.caches)
    : Promise.resolve(self.caches)
  ).then((storage) => storage.open("media-v1"));
  return mediaCachePromise;
}

self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);
  if (url.origin !== self.location.origin || !url.pathname.startsWith("/media/")) return;

  event.respondWith(
    (async () => {
      const cache = await getMediaCache();
      // Note: self.caches.match() would NOT find entries stored in a named bucket.
      const cached = await cache.match(event.request);
      return cached ?? fetch(event.request);
    })(),
  );
});

Opening a bucket in the worker with the same name returns the same bucket the page opened. A later open() can't change the bucket's quota, and it can change expires and upgrade persistence, so pass the same options everywhere to keep behavior predictable. Chromium's eviction query sorts non-persistent buckets by last access time, so a named best-effort bucket that you rarely touch goes before the default bucket that you use on every page load. In DevTools, named buckets appear under Application › Storage buckets, each showing its IndexedDB databases and caches.

Monitoring storage in production

You can't fix what you don't measure. Collect a coarse storage snapshot at most once a day per client:

storage-telemetry.js
import { getStorageSnapshot } from "./storage-estimate.js";

const LAST_REPORT_KEY = "storage-telemetry-last";
const ONE_DAY = 24 * 60 * 60 * 1000;

// Bucket sizes coarsely: enough to spot trends, too coarse to fingerprint devices.
function sizeBucket(bytes) {
  if (bytes == null) return "unknown";
  const mib = bytes / (1024 * 1024);
  if (mib < 10) return "<10MiB";
  if (mib < 50) return "10-50MiB";
  if (mib < 200) return "50-200MiB";
  if (mib < 1024) return "200MiB-1GiB";
  return ">1GiB";
}

export async function reportStorageUsage() {
  try {
    const last = Number(localStorage.getItem(LAST_REPORT_KEY) || 0);
    if (Date.now() - last < ONE_DAY) return;
    localStorage.setItem(LAST_REPORT_KEY, String(Date.now()));
  } catch {
    // localStorage unavailable (storage blocked): report anyway.
  }

  const snapshot = await getStorageSnapshot();
  if (!snapshot.available) return;

  const payload = JSON.stringify({
    usage: sizeBucket(snapshot.usage),
    caches: sizeBucket(snapshot.breakdown.caches),
    indexedDB: sizeBucket(snapshot.breakdown.indexedDB),
    persisted: snapshot.persisted,
    standalone: matchMedia("(display-mode: standalone)").matches,
  });

  navigator.sendBeacon("/telemetry/storage", new Blob([payload], { type: "application/json" }));
}

Also count QuotaExceededError occurrences (with the API that raised them) and detected storage wipes. The three signals together tell you whether users are running out of space, whether persistence requests succeed, and how often browsers are deleting data. Analytics for PWAs covers offline-safe event delivery.

Strategies to stay within quota

  1. Set explicit budgets per cache. Every runtime cache needs a maximum entry count and a maximum age. With Workbox, ExpirationPlugin({ maxEntries, maxAgeSeconds, purgeOnQuotaError: true }) enforces both and deletes the cache when a quota error occurs. See Precaching & Runtime Caching.
  2. Keep the precache lean. Precache the app shell and critical routes. Cache everything else at runtime, on demand. A 30 MB precache downloads on every install for every user, whether they need it or not.
  3. Delete old caches on activate. Versioned cache names (precache-v42) leave the old version behind unless your activate handler deletes it. See Lifecycle.
  4. Avoid opaque responses. Use CORS for every cross-origin resource you cache.
  5. Store compact formats. Store images as Blobs in IndexedDB or as responses in Cache Storage, never as base64 strings (a third larger). Store API data normalized in IndexedDB rather than caching every paginated response variant. For large files, use OPFS.
  6. Separate critical from disposable data. Use Storage Buckets where available, and request persistence for the bucket that holds user-created data.
  7. Stream instead of storing long media, or download it deliberately with Background Fetch when the user asks for offline copies.
  8. Plan for wipes. Treat eviction and the Safari 7-day cap as normal events. Re-sync from the server, rebuild caches on demand, and never keep the only copy of user data locally for long.

Browser support

Support data as of September 2026. See MDN and caniuse for live data.

Feature Chrome / Edge Firefox Safari
navigator.storage.persist() / persisted() ✅ 55 ✅ 57 ✅ 15.2 ⚠️
navigator.storage.estimate() ✅ 61 ✅ 57 ✅ 17
estimate().usageDetails ✅ 61 ❌ ❌
Permissions API "persistent-storage" ✅ 71 ✅ 53 ❌
Storage Buckets API ✅ 122 ❌ ❌
Bucket durability / locks 🧪 ❌ ❌
QuotaExceededError interface (quota, requested) ✅ 138 ❌ ❌ (in Technology Preview)
Predictable reported quota (usage + 10 GiB) ✅ 148 ⚠️ — —

⚠️ Safari has exposed persist() and persisted() since 15.2, but WebKit's storage policy (quotas based on disk size, heuristic persistence and estimate()) arrived with Safari 17 on macOS 14 and iOS 17. Chrome's predictable reported quota reached users through a server-side rollout from Chrome 144 and is the built-in default from Chrome 148. Incognito windows report the same value by default from Chrome 154. Android WebView doesn't support the "persistent-storage" permission name.

Common pitfalls

  • Treating quota - usage as free space. In current Chromium it's always about 10 GiB. In every engine, real free disk space can run out first.
  • Calling persist() on first load. Chromium refuses almost every first-visit request (without remembering it). Firefox shows an unexplained prompt. Ask after install or after an explicit user action.
  • Calling persist() from an iframe. Chromium only grants persistence to top-level documents.
  • Assuming persistence protects against Safari's 7-day cap. WebKit documents no such exemption. Installation to the Home Screen is the documented one.
  • Caching opaque responses at scale. Each one costs about 7 MiB of quota on average in Chromium.
  • Listening only to request.onerror in IndexedDB. Quota failures usually surface as a transaction abort event.
  • Deleting user data to recover from quota errors. Only reclaim data you can re-download.
  • Forgetting that the service worker is evicted with the data. After an eviction, the next visit has no controller and no caches. Make your first-visit path fast too.
  • Expecting Storage Buckets in the worker's global caches. self.caches is always the default bucket. Open the named bucket explicitly.

Debugging

  • Chromium DevTools › Application › Storage shows usage by type as a chart, has a Clear site data button, and can simulate a custom storage quota to test low-space behavior. Named buckets appear under Storage buckets. See Browser DevTools.
  • Incognito in Chromium is the quickest way to test small quotas on a real device: a few hundred megabytes, depending on RAM.
  • Firefox: about:preferences#privacy › Cookies and Site Data › Manage Data lists usage per site, and the site information panel (the padlock icon) shows the persistent-storage permission.
  • Safari: Web Inspector's Storage tab shows IndexedDB, Cache Storage and local storage for the page. On iOS, Settings › Apps › Safari › Advanced › Website Data lists per-site usage for Safari (Home Screen web apps keep their own data).
  • Force eviction scenarios by clearing site data between test runs, and run your first-visit and wipe-detection paths in automated tests.

Further reading

On this site

External references