Pruning Old Queued Shares When Quota Runs Low

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.

Most quota emergencies are not caused by too many pending shares. They are caused by entries that were delivered weeks ago and never removed, each still holding the file payload it was created with. Pruning is therefore mostly bookkeeping — and when it does have to make a real choice, the ordering matters: delivered before failed, failed before pending, and file bodies before whole records.

Feature Detection Gate

Pruning runs against an open database and a status index, so the gate is a schema check rather than an API check.

// prune-support.js
export function canPrune(db) {
  if (!db) return false;
  if (!db.objectStoreNames.contains('shareQueue')) return false;

  const tx = db.transaction('shareQueue', 'readonly');
  const hasStatusIndex = tx.objectStore('shareQueue').indexNames.contains('status');
  tx.abort();
  return hasStatusIndex;
}

Without a status index the pruner has to read every record to decide anything, which on a queue holding file bodies means loading megabytes to delete kilobytes. The index is what keeps pruning cheap enough to run often.

Reclamation Order Four tiers in order of safety. Delivered entries are removed first because they carry no intent. Expired failed entries follow. Then file bodies are shed from old pending entries while their metadata is kept. Only above a hard cap are whole pending records deleted, oldest first, and the count is reported to the user. 1 · delivered (status: done) — no intent remains largest reclaim, zero risk · usually enough on its own 2 · failed past its retention window kept 7 days for diagnosis and manual retry, then removed 3 · file bodies from old pending entries record survives at a few hundred bytes — still visible, still retryable 4 · whole pending records above the cap — oldest first last resort · must be counted and reported to the user

Step 1 — Reclaim the Free Space First

Delivered entries are pure overhead. Removing them is the whole job on a healthy queue.

// prune-delivered.js
const DELIVERED_TTL_MS = 24 * 60 * 60 * 1000;

export async function pruneDelivered(db, now = Date.now()) {
  const tx = db.transaction('shareQueue', 'readwrite');
  const index = tx.objectStore('shareQueue').index('status');

  let reclaimed = 0;
  let cursor = await index.openCursor(IDBKeyRange.only('done'));

  while (cursor) {
    const entry = cursor.value;
    if (now - (entry.completedAt ?? entry.createdAt) > DELIVERED_TTL_MS) {
      reclaimed += entryBytes(entry);
      await cursor.delete();
    }
    cursor = await cursor.continue();
  }

  await tx.done;
  return { reclaimed };
}

export function entryBytes(entry) {
  const fileBytes = (entry.payload?.files ?? [])
    .reduce((sum, file) => sum + (file.buffer?.byteLength ?? 0), 0);
  return fileBytes + JSON.stringify(entry.payload ?? {}).length;
}

Iterating the status index rather than the whole store means the cursor only visits delivered records, so the cost is proportional to what is being deleted rather than to the size of the queue.

Step 2 — Shed Payloads Before Records

When delivered entries are gone and space is still tight, the next reclaim should cost the user as little as possible. A file body is thousands of times larger than the metadata describing it.

// shed-payloads.js
import { entryBytes } from './prune-delivered.js';

const SHED_AFTER_MS = 3 * 24 * 60 * 60 * 1000;

export async function shedOldPayloads(db, now = Date.now()) {
  const tx = db.transaction('shareQueue', 'readwrite');
  const store = tx.objectStore('shareQueue');

  let reclaimed = 0;
  let shed = 0;
  let cursor = await store.index('status').openCursor(IDBKeyRange.only('pending'));

  while (cursor) {
    const entry = cursor.value;
    const hasFiles = (entry.payload?.files ?? []).length > 0;

    if (hasFiles && now - entry.createdAt > SHED_AFTER_MS) {
      reclaimed += entryBytes(entry);
      // Keep the record: title, text and url still describe what the user wanted.
      await cursor.update({
        ...entry,
        payload: { ...entry.payload, files: [] },
        filesShed: true,
        shedAt: now
      });
      shed += 1;
    }
    cursor = await cursor.continue();
  }

  await tx.done;
  return { shed, reclaimed };
}

filesShed: true is what makes this honest downstream. The share can still be delivered as a link, the UI can explain that the attachment was released, and the user can re-attach if they still want it — all of which is preferable to a record that silently lost its files or an entry that vanished entirely.

Step 3 — Cap the Pending Queue, and Say So

A pending entry is an unfulfilled promise, so deleting one is the last thing to try and the only reclaim that needs reporting.

// cap-pending.js
const MAX_PENDING = 100;

export async function capPendingQueue(db) {
  const tx = db.transaction('shareQueue', 'readwrite');
  const store = tx.objectStore('shareQueue');
  const pending = await store.index('status').getAll(IDBKeyRange.only('pending'));

  if (pending.length <= MAX_PENDING) {
    await tx.done;
    return { dropped: 0 };
  }

  // Newest first, then drop the tail: the oldest are least likely to still be wanted.
  const doomed = pending
    .sort((a, b) => b.createdAt - a.createdAt)
    .slice(MAX_PENDING);

  for (const entry of doomed) await store.delete(entry.id);
  await tx.done;

  return { dropped: doomed.length, oldest: doomed.at(-1)?.createdAt ?? null };
}

Whatever this returns must reach the interface. A queue that quietly discards a share the user was told would be sent has broken its only promise, and the count is the difference between a system that admits a limit and one that appears to lose data — the same reasoning behind showing queued share count in the UI.

Step 4 — Run the Ladder as One Escalating Pass

// prune-queue.js
import { pruneDelivered } from './prune-delivered.js';
import { shedOldPayloads } from './shed-payloads.js';
import { capPendingQueue } from './cap-pending.js';
import { storagePolicy } from './storage-policy.js';

export async function pruneQueue(db, { force = false } = {}) {
  const report = { reclaimed: 0, shed: 0, dropped: 0 };

  const delivered = await pruneDelivered(db);
  report.reclaimed += delivered.reclaimed;

  let policy = await storagePolicy();
  if (!force && policy.band === 'healthy') return report;    // stop early — nothing more is needed

  const payloads = await shedOldPayloads(db);
  report.reclaimed += payloads.reclaimed;
  report.shed = payloads.shed;

  policy = await storagePolicy();
  if (policy.band !== 'full' && policy.band !== 'tight') return report;

  const capped = await capPendingQueue(db);
  report.dropped = capped.dropped;
  return report;
}

Re-checking the policy between tiers is what keeps the pruner proportionate: on a healthy origin it does the cheap safe work and stops, and only a genuinely constrained device ever reaches the tier that deletes something the user wanted.

Escalation With Early Exit The pruner always removes delivered entries, then re-checks the storage band. If the origin is healthy it stops. Otherwise it sheds file bodies and re-checks again, and only a tight or full origin reaches the tier that deletes pending records. delete delivered always runs healthy now? yes stop — nothing the user wanted was touched no shed file bodies records survive cap pending only if still tight Every tier returns a count; anything that removed a pending share must be surfaced in the UI

Failure Modes and Recovery

Symptom Cause Minimal fix
Queue grows forever Delivered entries never removed Prune done on a schedule
Pruning is slow and janky Full-store scan Iterate the status index with a cursor
Pending share disappears silently Cap enforced without reporting Return and display the dropped count
Attachment gone with no explanation Files shed without a marker Set filesShed and explain in the UI
Space not reclaimed after deletes Transaction never committed Await tx.done before re-measuring
Pruner deletes too eagerly No band re-check between tiers Re-read the policy and exit early
When to Prune Running the pruner on startup and after each successful flush covers the moments when reclaimable entries appear. A periodic timer adds little on a queue that is already draining and costs battery on one that is idle. on startup clears whatever accumulated while the app was closed after each successful flush delivered entries become reclaimable here a periodic timer adds little on a draining queue and costs battery on an idle one — prefer the two events above

Browser and Platform Caveat

IndexedDB cursors, index ranges and transactional deletes behave consistently across Chrome, Safari, Firefox and Edge, so the pruner itself is portable. Two operational notes. Space is not returned to the origin’s accounting until the transaction commits, so calling estimate() immediately after a delete inside the same transaction reports the old figure — await the transaction first, which the escalation above does between tiers. And on iOS the whole origin can be cleared independently of anything your pruner does, so pruning reduces the chance of eviction by keeping usage low but never removes the need to detect it, as detecting when Safari evicts your share queue covers. Running the pruner on startup and after each successful flush is usually enough; a periodic timer adds little on a queue that is already draining.

FAQ

Which entries are safe to delete first?

Entries with status done. They have already been delivered, so removing them destroys no intent and usually reclaims the most space, since delivered file payloads are the largest records a queue holds.

Should pruning ever delete a pending share?

Only as a last resort and only above a cap, oldest first, and the user must be told. A pending entry represents something the person asked for and never got, so deleting it silently is the one outcome a queue exists to prevent.

Is it better to drop the file or the whole record?

Drop the file body first. The metadata is a few hundred bytes and keeps the share visible and partly retryable — the user can re-attach or share the link — whereas deleting the record removes every trace that the share was ever wanted.