Storage Quota and Eviction for Share Queues
This guide is part of Offline-First PWA Patterns for Web Share API.
An offline share queue makes a promise: capture the user’s intent now, deliver it when the network returns. That promise is only as good as the storage underneath it — and browser storage is a budget that can be exhausted by a single large payload, and a lease that can be revoked when the device runs low on space. A queue that ignores both loses exactly the shares the user cared most about, silently.
Three mechanisms cover it: ask for persistence so routine eviction skips your origin, measure before writing so a payload never overflows the budget, and prune continuously so the ceiling is never approached in the first place.
What Breaks Without This Pattern
Shares vanish between sessions. The device fills up, the browser evicts best-effort origins, and the queue is gone. Nothing failed at write time, so no error was ever logged — the user simply finds their pending shares missing.
A large file kills the write. A queued video pushes the origin past its budget and the transaction rejects with QuotaExceededError. Handled badly, the share is lost and the UI reports success, because the confirmation was optimistic.
The queue grows without bound. Delivered entries are never removed, so a queue that should hold three items holds three hundred, most of them long since sent. This is the most common cause of hitting a limit that was never tight.
Eviction is mistaken for a first run. The database is missing on startup, the app initialises a fresh one, and the user is never told that anything was lost.
Prerequisites
- An IndexedDB-backed queue — the schema in IndexedDB share queue persistence is the starting point.
- File payloads stored as
ArrayBufferorBlob, per storing file payloads as ArrayBuffer in IndexedDB. - A secure context:
navigator.storageis unavailable on insecure origins. - A rule about what the app may discard when space runs out, decided before you need it.
Browser Support Snapshot
| Capability | Chrome/Edge | Safari | Firefox | Notes |
|---|---|---|---|---|
StorageManager.estimate() |
Yes | Yes | Yes | Values are deliberately imprecise |
StorageManager.persist() |
Yes | Not exposed | Yes | Safari applies its own policy |
persisted() query |
Yes | Limited | Yes | Assume false when unavailable |
| Eviction under pressure | Best-effort origins | Aggressive, time-based | Best-effort origins | Installed PWAs fare better |
QuotaExceededError on write |
Yes | Yes | Yes | err.name is the reliable field |
Step 1 — Ask for Persistence at the Right Moment
persist() is granted on signals of engagement — installation, bookmarking, repeat visits, notification permission. Asking at the wrong moment wastes the request.
// persistence.js
export async function ensurePersistentStorage() {
if (!navigator.storage?.persist) return { supported: false, persisted: false };
if (await navigator.storage.persisted()) {
return { supported: true, persisted: true, alreadyGranted: true };
}
// Ask when the user has just demonstrated intent — e.g. queued their first share.
const granted = await navigator.storage.persist();
return { supported: true, persisted: granted };
}
There is no prompt in current browsers: the request is resolved from heuristics, so a false result is not a refusal to be retried in a loop. Ask once per session at most, after a meaningful action, and design the queue to survive a false.
Step 2 — Measure Before You Write
Writing hopefully and catching the failure works, but it is worse than checking: by the time the transaction rejects, the payload has been serialised and the user has been told the share was captured.
// quota.js
const HEADROOM_BYTES = 10_000_000; // never consume the last 10 MB
export async function quotaStatus() {
if (!navigator.storage?.estimate) {
return { known: false, usage: 0, quota: 0, available: Infinity };
}
const { usage = 0, quota = 0 } = await navigator.storage.estimate();
return {
known: true,
usage,
quota,
available: Math.max(0, quota - usage - HEADROOM_BYTES),
usedRatio: quota ? usage / quota : 0
};
}
export async function canStore(bytes) {
const status = await quotaStatus();
if (!status.known) return true; // no estimate available — attempt and catch
return bytes <= status.available;
}
Reserving headroom rather than filling to the ceiling is what keeps the app usable at the margin: caches, session data and the service worker all need room too, and an origin sitting at 100% of its quota misbehaves in ways that have nothing to do with the queue.
Step 3 — Prune Continuously
Most quota problems are hygiene problems. A queue that removes what it no longer needs rarely reaches a limit at all.
// prune.js
const DELIVERED_TTL_MS = 24 * 60 * 60 * 1000;
const FAILED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_PENDING = 100;
export async function pruneQueue(db, now = performance.timeOrigin + performance.now()) {
const tx = db.transaction('shareQueue', 'readwrite');
const store = tx.objectStore('shareQueue');
const all = await store.getAll();
const doomed = all.filter((entry) => {
if (entry.status === 'done') return now - entry.completedAt > DELIVERED_TTL_MS;
if (entry.status === 'failed') return now - entry.createdAt > FAILED_TTL_MS;
return false;
});
// Oldest pending entries beyond the cap: keep the newest, which the user still expects.
const pending = all.filter((e) => e.status === 'pending').sort((a, b) => b.createdAt - a.createdAt);
doomed.push(...pending.slice(MAX_PENDING));
for (const entry of doomed) await store.delete(entry.id);
await tx.done;
return doomed.length;
}
Note which end of the pending list is dropped. When a cap must be enforced, the oldest pending shares are the ones the user is least likely to still want — but they are also the ones most likely to represent a genuine failure to deliver, so surfacing the count in the UI matters, exactly as showing queued share count in the UI describes.
Step 4 — Recover from QuotaExceededError
Even with checks, a write can fail — the estimate is approximate and other tabs share the budget.
// resilient-write.js
import { pruneQueue } from './prune.js';
import { canStore } from './quota.js';
export async function enqueueShare(db, entry, bytes) {
if (!(await canStore(bytes))) {
await pruneQueue(db); // reclaim before the first attempt
}
try {
return await writeEntry(db, entry);
} catch (err) {
if (err.name !== 'QuotaExceededError') throw err;
const freed = await pruneQueue(db);
if (freed === 0) {
return { ok: false, reason: 'storage-full' }; // nothing left to reclaim
}
try {
return await writeEntry(db, entry); // exactly one retry
} catch (retryErr) {
if (retryErr.name === 'QuotaExceededError') return { ok: false, reason: 'storage-full' };
throw retryErr;
}
}
}
One retry, not a loop. If pruning freed nothing, the device is genuinely full and repeating the write only delays the honest answer — which is to tell the user the share could not be saved, and to offer sharing it now instead of later.
Step 5 — Detect Eviction Rather Than Assuming a First Run
An empty database on startup means one of two very different things.
// eviction-sentinel.js
const SENTINEL_KEY = 'queue-sentinel';
export async function checkForEviction(db) {
const sentinel = await db.get('meta', SENTINEL_KEY);
if (!sentinel) {
await db.put('meta', { key: SENTINEL_KEY, installedAt: Date.now(), everWritten: false });
return { firstRun: true, evicted: false };
}
const entries = await db.count('shareQueue');
if (sentinel.everWritten && entries === 0) {
return { firstRun: false, evicted: true, lastKnownCount: sentinel.lastCount ?? 0 };
}
return { firstRun: false, evicted: false };
}
Persisting everWritten alongside a last-known count turns a silent loss into something the app can report: “Some queued shares could not be kept — your device was low on space.” Users forgive a system that admits what happened far more readily than one that quietly forgets.
Platform Gotchas
Safari does not expose persist(). It applies its own time-based policy instead, and storage from sites not visited recently can be cleared. An installed PWA fares better than a tab, but neither is guaranteed.
estimate() is fuzzed. Browsers round and pad the numbers to limit fingerprinting, so treat the result as an upper bound and keep meaningful headroom.
Eviction is all-or-nothing. Browsers clear an entire origin rather than trimming records, so there is no partial state to recover — which is why the sentinel is the only reliable detector.
Every storage API shares one budget. IndexedDB, the Cache API and the file system all draw on the same quota, so an aggressive precache directly reduces the room available for queued shares — a trade-off worth making explicit alongside service worker caching strategies.
Private browsing shrinks everything. Quotas are much smaller and cleared at session end, so a queue in a private window should be treated as best-effort by design.
Testing and Verification
Fill the origin deliberately: write padding blobs until estimate() shows the budget nearly consumed, then queue a share and confirm the failure is caught, pruning runs, and the user is told. In Chrome DevTools, the Application panel reports usage and quota and can clear storage, which is the fastest way to simulate eviction — do it while the app is closed, then reopen and confirm the sentinel reports evicted: true rather than the app silently starting fresh. Verify that persist() is requested only after a real action, and check the queue count after a prune matches what the UI claims. Finally, run the same pass in a private window, where the quota is small enough that the limits are reached in seconds.
Two Different Failures That Look the Same
Storage problems reach the user as one symptom — a share that is missing — but they have two distinct causes with different fixes, and conflating them wastes a lot of time.
The first is a write that never happened. The quota was full at the moment the user tapped Share, the transaction was rejected, and if the confirmation had already been shown the user believes something was captured when nothing was. This failure is synchronous, catchable, and entirely preventable: measure before writing, prune when the band is tight, and confirm only after the transaction commits.
The second is a write that happened and was later erased. The browser evicted the origin while the app was closed, taking every queued share with it. Nothing was catchable, because no code was running. This failure can only be reduced — by keeping usage low, by requesting persistence, and by draining the queue quickly — and then detected afterwards with a sentinel so the user is told rather than left to notice.
The distinction matters because the instinctive response to “shares are going missing” is to add retries, and retries help with neither. A rejected write does not succeed on the second attempt unless something was freed in between; an evicted queue has nothing left to retry.
It also matters for how the two are monitored. A quota rejection is an event your code can count, so a rising rate is visible in telemetry immediately. Eviction produces no event at all, which is why the sentinel comparison on startup is not optional bookkeeping — it is the only instrument you have for a failure mode that is otherwise completely silent.
A Rule of Thumb for Sizing
If a single number helps, this one holds up in practice: keep the queue’s total footprint under a few megabytes, and keep any individual entry under one.
At that scale the queue is small enough that it is never the reason an origin is selected for eviction, small enough that pruning delivered entries alone keeps it healthy, and small enough that a full drain completes in one connectivity window rather than several. Above it, every guide in this section starts to matter at once.
The way to stay under it is almost always to queue a reference rather than the bytes — a link to the document instead of the document — and to accept that a share of genuinely large media is a share that belongs online rather than in a queue.
FAQ
How much storage does a web app actually get?
It is a share of free disk space rather than a fixed figure, and browsers report it deliberately imprecisely to limit fingerprinting. Treat StorageManager.estimate as an upper bound that can shrink when the device fills up, and never hard-code a number.
Does persistent storage guarantee my queue survives?
It exempts the origin from routine eviction under storage pressure, which is the common cause of loss. It does not survive the user clearing site data, uninstalling the app, or some platform-level cleanups, so the queue must still tolerate disappearing.
Why did my share queue disappear on iOS?
Safari applies its own eviction policy to storage from sites the user has not visited recently, and a browser-tab PWA is more exposed than an installed one. Detect the loss on startup with a sentinel record and re-establish state rather than assuming the data is still there.
Should file payloads live in IndexedDB or the Cache API?
Both draw on the same origin budget, so the choice is about ergonomics rather than space. IndexedDB keeps a payload and its metadata in one transactional record, which is what a queue wants; the Cache API is a better fit for whole HTTP responses.