Recovering from QuotaExceededError When Queueing Files
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.
The queue’s promise is that tapping Share while offline captures the intent. QuotaExceededError is the moment that promise is tested: the user has acted, the payload is ready, and the write refuses. Handled carelessly the share evaporates while the UI still says “queued”. Handled properly it degrades — smaller, or immediate, but never silently lost.
Feature Detection Gate
Detection is by error name, because messages differ between engines.
// quota-error.js
export function isQuotaError(err) {
if (!err) return false;
// name is standardised; message is not. Legacy code 22 covers very old engines.
return err.name === 'QuotaExceededError' || err.code === 22;
}
export function isTransientDbError(err) {
return err?.name === 'AbortError' || err?.name === 'TransactionInactiveError';
}
Keeping the two classifications separate matters: a transient transaction error is worth an immediate retry, while a quota error is not until something has actually been freed.
Step 1 — Write the Whole Entry in One Transaction
This is what makes “nothing was committed” true. A quota failure aborts the transaction, so a single-transaction write can never leave a half-record behind.
// write-entry.js
export async function writeEntry(db, entry) {
const tx = db.transaction('shareQueue', 'readwrite');
// Metadata and file buffers in ONE record, ONE transaction: all or nothing.
tx.objectStore('shareQueue').put(entry);
await tx.done; // rejects with QuotaExceededError if it will not fit
return { ok: true, id: entry.id };
}
Splitting the metadata and the file bodies across two transactions is the pattern that produces orphans: the metadata commits, the payload write fails, and the queue now holds an entry that can never be delivered and that every reader has to defend against.
Step 2 — Prune, Retry Once, Then Degrade
// resilient-enqueue.js
import { writeEntry } from './write-entry.js';
import { isQuotaError } from './quota-error.js';
import { pruneQueue } from './prune-queue.js';
export async function enqueueResilient(db, entry) {
try {
return await writeEntry(db, entry);
} catch (err) {
if (!isQuotaError(err)) throw err;
const report = await pruneQueue(db, { force: true });
if (report.reclaimed > 0) {
try {
return await writeEntry(db, entry); // exactly one retry
} catch (retryErr) {
if (!isQuotaError(retryErr)) throw retryErr;
}
}
// Still no room: keep the intent, drop the bytes.
const lite = {
...entry,
payload: { ...entry.payload, files: [] },
filesDropped: (entry.payload.files ?? []).length,
degradedAt: Date.now()
};
try {
await writeEntry(db, lite);
return { ok: true, id: lite.id, degraded: 'metadata-only', filesDropped: lite.filesDropped };
} catch {
return { ok: false, reason: 'storage-full' };
}
}
}
The degradation step is the one worth arguing for. A share of a photo with a caption and a link still has most of its value as a caption and a link — and the user can re-attach the photo later, which they cannot do if the entry never existed.
Step 3 — Offer an Immediate Share Instead
When nothing at all can be stored, there is still one thing the app can do: share it now, while the user is here.
// last-resort.js
export async function offerImmediateShare(payload) {
if (window.isSecureContext !== true || typeof navigator.share !== 'function') {
return { outcome: 'unavailable' };
}
const canSend = typeof navigator.canShare !== 'function' || navigator.canShare(payload);
if (!canSend) return { outcome: 'unavailable' };
try {
await navigator.share(payload); // must run inside the same user gesture
return { outcome: 'shared-now' };
} catch (err) {
return { outcome: err.name === 'AbortError' ? 'dismissed' : 'failed', error: err };
}
}
The gesture constraint shapes the whole flow: the offer has to be made inside the same interaction, so the recovery path must reach this point without an intervening dialog. In practice that means attempting the queue write first, synchronously deciding, and only then calling share().
Step 4 — Tell the Truth in the UI
The one unacceptable outcome is a confirmation for something that did not happen.
// enqueue-feedback.js
export function enqueueMessage(result) {
if (result.ok && !result.degraded) {
return { tone: 'success', text: 'Saved — it will send when you are back online.' };
}
if (result.degraded === 'metadata-only') {
const n = result.filesDropped;
return {
tone: 'warning',
text: `Saved without ${n === 1 ? 'the attachment' : `${n} attachments`} — your device is low on space.`
};
}
return {
tone: 'error',
text: 'There is not enough space to save this share. Send it now, or free some space and try again.',
action: 'share-now'
};
}
Attaching the share-now action to the failure message is what turns an error into a route forward. Optimistic confirmations are correct for a queue that succeeded — as connectivity-aware share UX describes — but they must be issued after the write resolves, never before it.
Failure Modes and Recovery
| Symptom | Cause | Minimal fix |
|---|---|---|
| Share silently lost | Confirmation shown before the write resolved | Confirm after tx.done |
| Error not caught | Matched on err.message |
Branch on err.name === 'QuotaExceededError' |
| Endless retry loop | Retry without freeing space | Prune first; retry exactly once |
| Half-written entry | Metadata and files in separate transactions | One record, one transaction |
| “Send now” fails on the recovery path | Gesture consumed by an intervening dialog | Decide synchronously and share in the same interaction |
| Attachment vanishes with no note | Degraded silently | Record filesDropped and say so |
Browser and Platform Caveat
QuotaExceededError is raised by name in Chrome, Safari, Firefox and Edge, and the transaction-abort semantics are consistent, so the single-transaction guarantee holds everywhere. The differences are in when it fires: Safari’s per-origin budget is smaller and can shrink over time, so the degraded path is reached far more often on iOS than on desktop, and in a private window it can be reached within a handful of queued files. Note also that the error can surface from a write that would have fitted a moment earlier — another tab on the same origin may have consumed the budget in between — which is exactly why the recovery ladder starts by freeing space rather than by assuming the payload is at fault. Test the whole path by filling the origin with padding blobs; it is the only way to be sure the messages and the “Send now” action behave under real pressure.
FAQ
How do I reliably detect a quota failure?
Check err.name === 'QuotaExceededError'. The name is standardised across engines while the message is not, so matching on message text produces code that works in one browser and silently misclassifies the failure in another.
Should I retry the write repeatedly?
No. Retry once, after actually freeing space. If pruning reclaimed nothing, the device is genuinely full and further attempts only delay telling the user, who can still act on the information by sharing immediately instead.
Is a partially written entry possible?
Not if the whole entry is written in one IndexedDB transaction — a quota failure aborts the transaction and nothing is committed. Splitting metadata and file bodies across separate transactions is what creates orphaned half-records.