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.
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.
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 |
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.