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