Estimating Remaining Quota with StorageManager

This guide is part of Storage Quota and Eviction for Share Queues, which itself is part of Offline-First PWA Patterns for Web Share API.

Queueing a share means writing its payload — often a file — into origin storage. That storage has a ceiling, and hitting it throws at the least convenient moment: after the user has tapped Share, after the payload has been serialised, and often after the UI has optimistically confirmed. navigator.storage.estimate() lets you ask first.

Feature Detection Gate

// estimate.js
export async function storageEstimate() {
  if (window.isSecureContext !== true) return { known: false, reason: 'insecure-context' };
  if (!navigator.storage?.estimate) return { known: false, reason: 'unsupported' };

  const { usage = 0, quota = 0, usageDetails } = await navigator.storage.estimate();
  return {
    known: true,
    usage,
    quota,
    free: Math.max(0, quota - usage),
    usedRatio: quota > 0 ? usage / quota : 0,
    usageDetails            // Chromium only: per-subsystem breakdown
  };
}

Returning known: false rather than zeros is deliberate. A missing estimate must mean “proceed and catch the failure”, not “no space available” — the second interpretation would disable queueing entirely on browsers that simply do not report.

Reading the Usage Ratio The used-versus-quota ratio divides into four bands. Below sixty per cent the queue operates normally. Between sixty and eighty per cent pruning runs more often. Between eighty and ninety-five per cent only small payloads are accepted. Above ninety-five per cent new file payloads are refused and the user is offered an immediate share instead. 0 – 60% 60 – 80% 80 – 95% 95+ healthy — normal operation queue anything that fits; prune on schedule pressure — prune eagerly drop delivered entries before every write tight — small payloads only queue metadata; skip large file bodies full — refuse and explain offer sharing now instead of queueing

Step 1 — Turn the Numbers into a Policy

Raw bytes are not a decision. Convert them into a band, and let the band drive behaviour.

// storage-policy.js
import { storageEstimate } from './estimate.js';

const HEADROOM_FLOOR = 10_000_000;      // always keep 10 MB for the app itself
const HEADROOM_RATIO = 0.05;            // plus 5% of the quota

export async function storagePolicy() {
  const estimate = await storageEstimate();
  if (!estimate.known) {
    return { band: 'unknown', available: Infinity, canQueueFiles: true };
  }

  const headroom = Math.max(HEADROOM_FLOOR, estimate.quota * HEADROOM_RATIO);
  const available = Math.max(0, estimate.free - headroom);
  const ratio = estimate.usedRatio;

  const band =
    ratio < 0.6 ? 'healthy' :
    ratio < 0.8 ? 'pressure' :
    ratio < 0.95 ? 'tight' : 'full';

  return {
    band,
    available,
    canQueueFiles: band !== 'full',
    shouldPruneFirst: band === 'pressure' || band === 'tight',
    maxPayloadBytes: band === 'tight' ? Math.min(available, 2_000_000) : available
  };
}

A floor plus a ratio covers both ends of the device range: on a phone with a small quota the fixed 10 MB dominates, and on a desktop with tens of gigabytes the percentage does. A single fixed number gets one of those cases badly wrong.

Step 2 — Check Before Serialising, Not After

The check is worth almost nothing if it runs after the expensive part.

// guarded-enqueue.js
import { storagePolicy } from './storage-policy.js';
import { pruneQueue } from './prune.js';

export async function enqueueWithQuotaCheck(db, payload) {
  const bytes = (payload.files ?? []).reduce((sum, file) => sum + file.size, 0);
  let policy = await storagePolicy();

  if (policy.shouldPruneFirst) {
    await pruneQueue(db);
    policy = await storagePolicy();          // re-read: pruning may have changed the band
  }

  if (bytes > policy.maxPayloadBytes) {
    return { ok: false, reason: 'insufficient-storage', bytes, available: policy.maxPayloadBytes };
  }

  // Only now do we serialise file bodies into ArrayBuffers.
  return writeQueueEntry(db, payload);
}

Re-reading the policy after pruning is the step most implementations skip, and it matters: the pruner may have freed enough to move the origin from tight back to healthy, and a stale policy would reject a share that now fits comfortably.

Step 3 — Use usageDetails to Find the Real Consumer

Where it is available, the breakdown answers the question “what is actually filling this origin?” — and the answer is rarely the queue.

// usage-breakdown.js
import { storageEstimate } from './estimate.js';

export async function usageBreakdown() {
  const estimate = await storageEstimate();
  if (!estimate.known || !estimate.usageDetails) return null;

  const details = estimate.usageDetails;      // e.g. { indexedDB, caches, serviceWorkerRegistrations }
  const total = Object.values(details).reduce((sum, value) => sum + value, 0) || 1;

  return Object.entries(details)
    .map(([subsystem, bytes]) => ({
      subsystem,
      bytes,
      share: bytes / total,
      megabytes: +(bytes / 1_000_000).toFixed(1)
    }))
    .sort((a, b) => b.bytes - a.bytes);
}

A queue of a few dozen shares is usually kilobytes; a precache of an image-heavy app is megabytes. When the origin is under pressure, the breakdown normally points at the Cache API, which makes the fix a caching-strategy change rather than a queue change — the trade-off described in service worker caching strategies.

Where the Space Actually Goes A stacked bar of a typical origin's usage. The Cache API holds the large majority, service worker registrations a small slice, and the IndexedDB share queue a very small remainder — so pressure is usually a caching problem rather than a queue problem. Typical usageDetails for an image-heavy PWA caches — 58 MB SW — 14 MB IDB the queue is not the problem 40 pending shares ≈ 6 MB including file bodies fix the cache, not the queue version and trim precached assets first A Floor and a Percentage, Not One Number On a phone with a small quota the fixed ten-megabyte floor dominates and protects the app. On a desktop with tens of gigabytes the percentage dominates instead. A single fixed number gets one of the two cases badly wrong. phone · quota 120 MB 5% = 6 MB — too little to protect the app floor of 10 MB wins headroom = 10 MB desktop · quota 40 GB floor of 10 MB is negligible 5% = 2 GB wins headroom scales with the device

Failure Modes and Recovery

Symptom Cause Minimal fix
Queueing disabled everywhere Missing estimate read as zero space Return known: false and proceed
QuotaExceededError despite a check Estimate is approximate; other tabs write too Keep the catch-and-retry path
Big payload rejected on a large device Fixed headroom too aggressive Use a floor plus a percentage
Share refused right after a prune Policy not re-read Re-evaluate after freeing space
Memory spike before the check Files serialised first Check file.size before reading bytes
Queue blamed for storage pressure Breakdown never inspected Read usageDetails before optimising
The Quota Is Not Fixed Because the origin budget is a share of free disk space, it shrinks when the device fills up. A payload that fitted a minute ago may not fit now, which is why a successful estimate never removes the need to handle a failed write. quota at 09:00 quota at 14:00 — the user filled the device the same 40 MB share fits in the morning and raises QuotaExceededError in the afternoon

Browser and Platform Caveat

StorageManager.estimate() is available in Chromium, Firefox and Safari on secure origins, but the values are intentionally coarse: browsers round, pad and sometimes cap them to prevent fingerprinting and to avoid leaking how much other sites have stored. usageDetails is a Chromium extension and absent elsewhere, so treat it as diagnostics rather than logic. Two further notes. The quota itself is a share of free disk space, so it can shrink between two calls if the user fills the device — a payload that fitted a minute ago may not fit now, which is why the write still needs its own error handling. And in a private window the quota is much smaller and cleared at session end, so the tight and full bands are reached quickly there; that makes private browsing a genuinely useful test environment for this code path.

Treat It as a Bound, Not a Measurement

The single most useful framing for estimate() is that it returns an upper bound on what you can use rather than an accounting of what you have used.

Browsers round and pad the figures deliberately, the quota itself moves as the device fills, and other tabs on the same origin are writing against the same budget while you read it. A decision made from those numbers should therefore always be conservative and always be backed by a real error handler.

That is not a weakness of the API — an exact figure would be a fingerprinting vector and would leak how much other sites have stored. It simply means the estimate belongs in the branch that decides whether to offer a large share, never in the branch that decides whether the write will succeed.

FAQ

Why are the numbers from estimate() imprecise?

Precise usage would be a fingerprinting vector and could expose what a user has stored on other sites, so browsers round and pad the figures. Use them as a bound for decisions, never as an exact accounting of your own data.

Does the usage figure only cover my data?

It covers the whole origin — IndexedDB, the Cache API, service worker registrations and more — which is what you want when deciding whether a write will fit, but means it is not a measure of your queue alone. Track your own record sizes if you need that.

How much headroom should I reserve?

Enough that the app still functions when the queue is full — typically a fixed floor of a few megabytes plus a fraction of the remaining budget. The exact figure matters less than never letting a single write consume the last of the space.