skip to content

A web app that writes large blobs to client storage sees the writes fail for some users. Which error does a browser raise when an origin's storage quota is exhausted, and what determines how much room a given user actually has?

level: middleimportance: should knowfreq 44%

answer

  1. one DOMException name to branch on
  2. three different delivery shapes
  3. transaction rolls back, not just the row
  4. the disk sets the ceiling
  5. estimate first, prune, retry once

basics

~20 s

The browser raises a QuotaExceededError DOMException: thrown synchronously by localStorage.setItem, aborting the transaction in IndexedDB, rejecting the promise in the Cache API. The ceiling is derived from the device's free disk space, so it varies per machine and shrinks as the disk fills.

solid answer

~50 s

Exceeding the origin's quota surfaces as a `QuotaExceededError` `DOMException`, but each API delivers it differently: `localStorage.setItem()` throws synchronously, an IndexedDB write aborts the whole transaction and reports the error there, and `Cache.put()` rejects its promise. Code that only catches one of those shapes will miss the others. The reason it fails for some users and not others is that quota is not a constant — browsers compute it from disk space, so a laptop with 400 GB free and a nearly-full phone get very different ceilings, and the same device's ceiling falls as its disk fills. Private-browsing sessions get far less again. The handling pattern is to catch the error by name, free space you own by deleting stale caches or expired records, retry once, and degrade honestly if that fails — and to check `navigator.storage.estimate()` for headroom before a large import rather than discovering the ceiling mid-write.

code

javascript · 25 lines
javascript
async function writeWithQuotaHandling(cache, request, response) {
  try {
    await cache.put(request, response.clone());
    return true;
  } catch (err) {
    if (err.name !== 'QuotaExceededError') throw err;

    const freed = await pruneStaleCaches();
    if (!freed) return false;

    try {
      await cache.put(request, response.clone());
      return true;
    } catch {
      return false;
    }
  }
}

async function pruneStaleCaches() {
  const names = await caches.keys();
  const stale = names.filter((name) => !name.startsWith('app-v3-'));
  await Promise.all(stale.map((name) => caches.delete(name)));
  return stale.length > 0;
}

go deeper

for a junior

Know that client storage can run out and that the browser signals it with a QuotaExceededError, so writes must be wrapped in error handling rather than assumed to succeed.

for a middle

Be ready to explain the three delivery shapes — a synchronous throw, an aborted IndexedDB transaction, a rejected Cache API promise — and that quota is computed from disk space rather than fixed.

for a senior

Show the production response: check headroom before large writes, prune data you own and retry once, distinguish a full store from a blocked one by exception name, and surface honest failure to the user.

for a principal

Own storage as a shared budget across features: who gets how much, which caches are self-pruning, and how the product behaves on constrained devices where the ceiling is small and shrinking.

## The error When a write would push an origin past its quota, the browser raises a `DOMException` whose `name` is `"QuotaExceededError"`. That name is the thing to branch on — not the message, which is not standardised and differs between browsers. ```js try { await cache.put(request, response); } catch (err) { if (err.name === 'QuotaExceededError') { await freeSpace(); } else { throw err; } } ``` ## Three delivery shapes The same error arrives through three different channels, which is where a lot of real bugs live. **Web Storage throws synchronously.** `localStorage.setItem()` raises the exception on the spot, so a plain `try/catch` around the call works — and a missing one takes down whatever ran that line. **IndexedDB aborts the transaction.** A write that does not fit does not just fail that request; the transaction aborts, and everything else it had done is rolled back with it. The error surfaces on the request or on the transaction's `abort`/`error` handling, so an app that only inspects individual request errors can miss it entirely and silently lose a batch it believed had been written. **The Cache API rejects.** `Cache.put()`, `add()` and `addAll()` return promises that reject. `addAll()` is atomic, so a batch that does not fit stores nothing. An app that writes through more than one of these needs handling for each shape; there is no single global event announcing that the origin is full. ## Why the ceiling differs between users Quota is derived from the device, not from the application: - Browsers compute it from disk space — Chromium documents an origin ceiling of roughly 60% of total disk size, and Firefox derives a global limit from available space and then caps what any one site's group may take. - Because it tracks *available* space, the same machine's ceiling **falls over time** as the user's disk fills. Code that worked in January can fail in June with no change to the app. - Private or incognito sessions get a much smaller allowance, and everything is discarded at the end of the session. - The budget is shared across the origin's quota-managed storage, so a bloated cache directly shrinks the room a database has. Web Storage is separately pinned to a small per-origin cap of a few megabytes, so it hits its own wall long before the pool is exhausted. - Under storage partitioning, the accounting follows the storage key, so an embedded frame's ceiling belongs to that partition rather than to the origin globally. That list is the answer to "why only some users": nothing about the users' behaviour differs, only their disks. ## Handling it properly **Look before you leap.** Before an import, an offline sync or a large media download, compare the payload size with the headroom from `navigator.storage.estimate()`. Refusing up front with a clear message beats a half-written store. ```js const { usage = 0, quota = 0 } = await navigator.storage.estimate(); if (quota - usage < requiredBytes * 1.5) { return { ok: false, reason: 'insufficient-space' }; } ``` The 1.5 multiplier is deliberate: the estimate is approximate, and stored size exceeds written size because of indexes, metadata and block rounding. **Free space you own, then retry once.** Delete superseded caches, drop expired records, discard regenerable derived data. One retry after a successful reclaim is reasonable; a retry loop against a genuinely full disk is not. **Fail honestly.** If space cannot be reclaimed, tell the user that offline data could not be saved and keep the app functional online. Silently swallowing the exception produces the worst outcome — a user who believes their work is stored locally when it is not. **Cap what you store.** Give each cache an explicit budget and an eviction policy of your own, so the origin never approaches its ceiling in the first place. Self-pruning storage also makes the origin a smaller target for browser-driven eviction. ## The related failure that is not a quota problem A write can also fail because storage is unavailable rather than full: some privacy configurations block storage for an origin outright, and third-party frames may find storage restricted. Those surface as different errors — commonly a `SecurityError` — and no amount of freeing space will fix them. Distinguish by `name` before deciding what to do, and always have a path where the app works with no client-side persistence at all.

  • Why can a write that succeeded for months suddenly start failing on the same device?
    Because quota tracks available disk space rather than being fixed. As the user's disk fills, the computed ceiling drops, so an app whose footprint never changed can cross a line that moved underneath it. The same effect appears when another part of the origin grows, since all quota-managed storage draws on one shared budget.
  • What happens to the rest of an IndexedDB transaction when one write exceeds the quota?
    The transaction aborts and everything it had already done is rolled back, so a batch that appeared to be writing successfully stores nothing. That is why handling has to be attached to the transaction's abort and error paths, not only to individual request errors — otherwise the failure is silent and the app believes the batch landed.
  • How would you distinguish a quota failure from storage being blocked entirely?
    By reading the DOMException's name rather than its message. A full origin reports QuotaExceededError, while a blocked or unavailable store typically reports SecurityError or throws when the store is opened. Freeing space fixes only the first; the second needs a fallback path where the app runs with no client-side persistence at all.

saying these in an interview costs you the question

  • Assumes every browser gives the same fixed quota
  • Catches only the synchronous localStorage throw
  • Thinks only the failing IndexedDB write is lost
  • Retries the failed write in a loop without freeing space
  • Swallows the error so the user believes data was saved

context