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.

Four Degradation Steps After a Quota Failure A failed write triggers pruning and a single retry. If that fails, the entry is written without file bodies so the intent survives. If even that fails, the user is offered an immediate share instead of a queued one, and only if they decline is the share reported as not saved. write rejected — QuotaExceededError transaction aborted; nothing was committed 1 · prune, then retry exactly once delivered entries removed first — usually enough 2 · store metadata only — drop the file bodies a few hundred bytes; the link still sends, the user can re-attach 3 · offer sharing now open the sheet instead of queueing 4 · report honestly "couldn't be saved" — never a false confirmation

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.

When to Confirm Two orderings. Confirming before the write completes shows a success message that a quota failure then contradicts, and the share is lost silently. Confirming after the transaction resolves means the message always matches the outcome, including the degraded and failed cases. Confirm first, write later — wrong tap → "Saved!" → write → QuotaExceededError → nothing exists the user believes the share is queued; it is not, and nothing ever tells them Write first, confirm the actual outcome — right tap → write → resolved → message matches: saved · saved without attachments · not saved the failure case carries a "Send now" action, so the user is never stuck

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
One Transaction Means No Orphans Writing metadata and file bodies in separate transactions lets the first commit while the second fails, leaving an entry that can never be delivered. A single transaction aborts entirely on a quota failure, so the queue holds only complete records. two transactions metadata commits ✓ file bodies fail on quota ✗ an undeliverable orphan remains one transaction quota failure aborts the whole write nothing is committed at all the recovery path starts from a clean state

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.