IndexedDB Persistence for Offline Share Queues
This guide is part of Offline-First PWA Patterns for Web Share API.
An offline share queue is only as reliable as the storage layer underneath it. The orchestration around it — deciding when to flush, registering Background Sync, polling on visibilitychange — is covered by the offline share queue implementation guide. This reference goes one layer deeper: how the records themselves are shaped, migrated, written, read, and pruned inside IndexedDB so that a share intent captured on a subway platform is still intact, valid, and shareable when the device reconnects an hour later. Get the schema and transaction discipline wrong and entries silently corrupt, go stale, or exhaust quota — failures that never surface in a quick DevTools test but bite in the field.
The diagram below shows the object store schema and the transaction paths that write and read it.
Problem Framing
The visible bug is always the same: a user tapped share while offline, the app promised the share was saved, and it never went out. The root cause is almost always in the storage layer, and it takes one of a few shapes.
- Non-durable records. A raw
FileorBlobwas stored by reference. The reference is structured-cloneable, so the write appears to succeed, but the backing bytes are released when the service worker is torn down. On reconnect the record deserialises into a zero-byte or detached file andnavigator.share()throws aDataError. - Aborted transactions. An
awaiton afetchorcrypto.subtle.digestwas placed inside an open transaction. IndexedDB transactions auto-commit the moment the microtask queue drains with no pending requests, so the laterput()runs against a dead transaction and throwsTransactionInactiveError. - Schema drift. A new field or index was added without bumping the version, so returning users run against the old store and reads by the new index throw
NotFoundError. - Silent eviction and quota exhaustion. The queue grew unbounded because completed entries were never purged, or the origin’s storage was evicted wholesale — common on iOS Safari after seven idle days.
Every one of these is a storage-layer concern, invisible to the queue orchestration above it. This guide fixes each at the source. It is a companion to the broader offline-first architecture overview; where that page walks the full lifecycle, this one owns the bytes on disk.
Prerequisites Checklist
Browser Support Snapshot
| Capability | Chrome (Android/desktop) | Safari (iOS/macOS) | Firefox | Edge |
|---|---|---|---|---|
IndexedDB v2 (getAll, openKeyCursor) |
58+ | 10.1+ | 51+ | 79+ |
navigator.storage.estimate() |
61+ | Partial (15.2+, may omit usage) |
57+ | 79+ |
Durable structured clone of ArrayBuffer |
Yes | Yes | Yes | Yes |
Persistent storage (storage.persist()) |
Yes (heuristic) | No | Yes (prompt) | Yes |
| 7-day eviction of idle storage | No | Yes (ITP) | No | No |
The two rows that shape design are Safari’s lack of persistent storage and its idle-eviction policy. On iOS the queue is inherently best-effort; the architecture must flush early and never assume a record written today is present tomorrow.
Step-by-Step Implementation
Step 1 — Open the database and run a versioned upgrade
indexedDB.open(name, version) is the only entry point, and onupgradeneeded is the only place you may create or alter stores and indexes. Branch on event.oldVersion so each schema change is additive and idempotent — a user on version 0 (fresh install) and a user on version 1 both arrive at the correct final schema.
// share-db.js
export const DB_NAME = 'share-queue';
export const DB_VERSION = 2;
export const STORE = 'shareQueue';
export function openShareDB() {
if (!window.isSecureContext || !('indexedDB' in window)) {
return Promise.reject(new Error('IndexedDB share queue requires a secure context.'));
}
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = request.result;
const tx = event.target.transaction; // the versionchange transaction
// v1: initial store + indexes
if (event.oldVersion < 1) {
const store = db.createObjectStore(STORE, { keyPath: 'id' });
store.createIndex('status', 'status', { unique: false });
store.createIndex('createdAt', 'createdAt', { unique: false });
}
// v2: add a unique content hash index for deduplication
if (event.oldVersion < 2) {
const store = tx.objectStore(STORE);
if (!store.indexNames.contains('contentHash')) {
store.createIndex('contentHash', 'contentHash', { unique: true });
}
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
request.onblocked = () => reject(new Error('Upgrade blocked by an open connection in another tab.'));
});
}
Note the guarded if (!store.indexNames.contains(...)) before createIndex. It keeps the migration safe to re-run and prevents a ConstraintError if a partial upgrade left the index in place. When you add the unique contentHash index to a store that already holds duplicate records, the upgrade transaction aborts — migrate existing rows first, which the deduplication guide walks through.
Step 2 — Define the object store schema
The schema is deliberately flat and query-driven. Every field earns its place either as the key, an index, or normalised payload data.
// schema.js — the shape every queue record must satisfy
export function buildQueueRecord(payload, contentHash) {
return {
id: crypto.randomUUID(), // keyPath — stable, collision-free primary key
createdAt: Date.now(), // indexed — powers TTL purges and ordering
status: 'pending', // indexed — 'pending' | 'done' | 'failed'
retries: 0, // retry bookkeeping for the flush
contentHash, // uniquely indexed — blocks duplicate intents
payload: {
title: String(payload.title ?? ''),
text: String(payload.text ?? ''),
url: String(payload.url ?? ''),
files: [] // filled with { buffer, name, type, lastModified }
}
};
}
Three design rules drive this:
- The
keyPathis a UUID, not the payload. Using a hash of the content as the primary key seems tempting, but you want the hash on a separate unique index so a duplicate write fails predictably withConstraintErrorrather than silently overwriting viaput(). - Index only what you query. The flush reads by
status; the purge reads bycreatedAt; dedup reads bycontentHash. Nothing else is indexed, because every index is maintained on every write and costs storage. - The payload is normalised at write time. Coercing to strings and stripping unknown fields here means the record read back weeks later matches exactly what
navigator.canShare()expects.
Step 3 — Add a serialised entry in one transaction
One logical operation, one transaction. Do all async work — hashing, file serialisation, quota checks — before opening the transaction, then keep the transaction body synchronous so it cannot auto-commit underneath you.
// enqueue.js
import { openShareDB, STORE } from './share-db.js';
import { buildQueueRecord } from './schema.js';
const MAX_ENTRY_BYTES = 5 * 1024 * 1024; // 5 MB ceiling per record
export async function enqueueEntry(payload, contentHash, serialisedFiles) {
// 1. All async prep happens BEFORE the transaction opens.
await assertQuotaAvailable();
const record = buildQueueRecord(payload, contentHash);
record.payload.files = serialisedFiles; // already { buffer, name, type, lastModified }
const approxBytes = serialisedFiles.reduce((n, f) => n + f.buffer.byteLength, 0);
if (approxBytes > MAX_ENTRY_BYTES) {
throw new RangeError(`Entry exceeds ${MAX_ENTRY_BYTES} byte cap.`);
}
const db = await openShareDB();
// 2. Transaction body is synchronous — no awaits inside.
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
const request = tx.objectStore(STORE).add(record);
request.onsuccess = () => resolve(record.id);
tx.oncomplete = () => db.close();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error ?? new Error('Enqueue transaction aborted.'));
});
}
async function assertQuotaAvailable() {
if (!navigator.storage?.estimate) return; // unknown — let the write attempt proceed
const { usage = 0, quota = 0 } = await navigator.storage.estimate();
if (quota && usage / quota > 0.9) {
throw new Error('Storage quota near limit; purge before enqueueing.');
}
}
Resolve on request.onsuccess but close on tx.oncomplete: the request fires when the write is staged, the transaction completes when it is durably committed. Reject on both tx.onerror and tx.onabort — a unique-index violation aborts the whole transaction, and only onabort carries that signal on some engines.
Step 4 — Query pending entries by index
Reads use a readonly transaction and go through the status index so the engine returns only the records the flush needs, without scanning the whole store.
// query.js
import { openShareDB, STORE } from './share-db.js';
export async function readByStatus(status) {
const db = await openShareDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readonly');
const index = tx.objectStore(STORE).index('status');
const request = index.getAll(status);
request.onsuccess = () => resolve(request.result);
tx.oncomplete = () => db.close();
tx.onerror = () => reject(tx.error);
});
}
// Ordered, memory-bounded iteration for large queues.
export async function forEachPending(callback) {
const db = await openShareDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readonly');
const index = tx.objectStore(STORE).index('createdAt');
const cursorReq = index.openCursor(); // ascending: oldest first (FIFO)
cursorReq.onsuccess = () => {
const cursor = cursorReq.result;
if (!cursor) return;
if (cursor.value.status === 'pending') callback(cursor.value);
cursor.continue();
};
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => reject(tx.error);
});
}
Use getAll for small queues and a cursor over the createdAt index when the queue can grow large — the cursor streams records one at a time instead of materialising the entire result set in memory, and iterating the createdAt index gives you FIFO order for free. Reconstructing the files and calling navigator.share() on each record is the flush step, shared with the receiving shared files and text pipeline on the target side.
Step 5 — Purge terminal entries and enforce quota
A queue that only grows will eventually hit QuotaExceededError on the next write. Purge on every flush: delete done entries immediately and failed entries past a TTL, so failures stay visible long enough to surface in the UI but not forever.
// purge.js
import { openShareDB, STORE } from './share-db.js';
const FAILED_TTL_MS = 72 * 60 * 60 * 1000; // keep failed entries 72h for UI
export async function purgeTerminal() {
const db = await openShareDB();
const cutoff = Date.now() - FAILED_TTL_MS;
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
const store = tx.objectStore(STORE);
const cursorReq = store.index('status').openCursor();
let removed = 0;
cursorReq.onsuccess = () => {
const cursor = cursorReq.result;
if (!cursor) return;
const { status, createdAt } = cursor.value;
if (status === 'done' || (status === 'failed' && createdAt < cutoff)) {
cursor.delete();
removed += 1;
}
cursor.continue();
};
tx.oncomplete = () => { db.close(); resolve(removed); };
tx.onerror = () => reject(tx.error);
});
}
cursor.delete() inside a readwrite cursor removes the current record without a second lookup, and because it runs inside the same transaction the whole purge is atomic — either every eligible record is gone or none are, even if the worker is killed mid-loop.
Payload Validation and Error Boundary
Validation for a persisted queue happens twice: once before writing, so you never store an unshareable record, and once after reading, because the OS share-target list can change between enqueue and flush. Run navigator.canShare() on the reconstructed payload immediately before navigator.share().
// validate.js
export function isRecordShareable(record) {
if (!window.isSecureContext || typeof navigator.share !== 'function') return false;
const data = {
title: record.payload.title,
text: record.payload.text,
url: record.payload.url
};
if (record.payload.files?.length) {
data.files = record.payload.files.map(
(f) => new File([f.buffer], f.name, { type: f.type, lastModified: f.lastModified })
);
}
if (typeof navigator.canShare !== 'function') return true; // no pre-flight available
return navigator.canShare(data);
}
The storage-specific DOMException and error surface you must handle:
| Error name | Storage-layer cause | Correct response |
|---|---|---|
TransactionInactiveError |
An await inside an open transaction let it auto-commit |
Move all async work before db.transaction(); keep the body synchronous |
ConstraintError |
add() hit a duplicate on the unique contentHash index |
Treat as “already queued” — skip, do not error the user |
QuotaExceededError |
Write exceeded origin quota | Purge terminal entries, then retry; enforce the per-entry byte cap |
DataError |
Stored File by reference; bytes detached after SW restart |
Serialise to ArrayBuffer before writing (see the file-payload guide) |
VersionError |
Open requested a version lower than the on-disk DB | Never lower DB_VERSION; only ever increment it |
NotFoundError |
Read used an index created in a version the user has not upgraded to | Guard reads and add the index in a versioned upgrade |
Platform Gotchas
iOS/iPadOS Safari — seven-day eviction. Intelligent Tracking Prevention evicts all script-writable storage, IndexedDB included, after seven days without user interaction with the site. A share queued and forgotten can vanish entirely. Mitigation: flush on every visibilitychange, keep entries small, and never present the queue as permanent storage to the user. navigator.storage.persist() is not available on iOS, so you cannot opt out.
Private / incognito mode. Chromium and Firefox give private sessions a small, memory-backed quota that is discarded when the last private window closes; Safari historically threw QuotaExceededError on the first write in private mode. Detect this by attempting a tiny probe write during initialisation and degrading to an in-memory queue plus an immediate clipboard fallback if it fails.
Multi-tab upgrades. When one tab holds an open connection at the old version and another tab loads new code with a higher DB_VERSION, the upgrade fires onblocked and stalls until the old connection closes. Always db.close() in tx.oncomplete, and listen for versionchange on live connections to close them proactively so a background tab cannot deadlock an upgrade.
Chromium eviction under storage pressure. Without storage.persist(), Chromium’s default “best-effort” bucket can be cleared under disk pressure. Requesting persistence (granted heuristically based on engagement and installation) protects a queue on installed PWAs. It is a no-op on Safari, so keep it as an enhancement, not a dependency.
structuredClone vs. IndexedDB serialisation. IndexedDB uses the structured clone algorithm, which handles ArrayBuffer, typed arrays, Date, and plain objects — but not class instances with methods, Function, or DOM nodes. Keep records to primitives, plain objects, and buffers so a write never throws DataCloneError.
Testing and Verification
DevTools inspection (Chromium):
- Open Application → Storage → IndexedDB →
share-queue→shareQueue. Confirm records appear with the expectedid,status,createdAt, andcontentHashcolumns. - Expand a record and verify
payload.files[0].buffershows as anArrayBufferwith a non-zero byte length — not aFileobject. AFilehere is the bug that producesDataErroron flush. - In Application → Storage, read the “Usage” figure and cross-check it against
await navigator.storage.estimate()in the console. - Simulate a schema upgrade: change
DB_VERSIONfrom 1 to 2 in your source, reload, and confirmonupgradeneededruns once and thecontentHashindex appears under the store’s Indexes list.
Durability across service worker restarts:
Quota and purge:
Physical iOS device: enqueue a share, leave the PWA untouched for eight days, reopen it, and verify your code handles an empty store gracefully rather than assuming the entry is still there.
FAQ
Why does my queue entry disappear after the service worker restarts?
The record survives if it was written inside a committed transaction — what breaks is any in-memory File or Blob reference you stored, because the browser can release the backing bytes when the worker is killed. Store the file’s bytes as an ArrayBuffer via await file.arrayBuffer() so the record is fully self-contained, as detailed in storing file payloads as ArrayBuffer.
Do I need a separate transaction for each queue operation?
One transaction per logical operation, not per statement. A transaction auto-commits as soon as its request queue drains, so await-ing an unrelated promise inside it closes it and the next request throws TransactionInactiveError. Do all hashing, serialisation, and quota checks before calling db.transaction(), then keep the transaction body synchronous.
How do I change the schema without deleting user data?
Increment DB_VERSION and branch on event.oldVersion inside onupgradeneeded. Create only the stores and indexes missing from the previous version, guarding each with objectStoreNames.contains(...) or indexNames.contains(...). That handler runs inside a versionchange transaction — the only context where createObjectStore and createIndex are legal.
What happens to IndexedDB data on iOS Safari over time?
Safari can evict all of an origin’s script-writable storage after seven days without interaction, so a never-flushed share can vanish. Flush aggressively on visibilitychange, keep payloads small, and treat the queue as best-effort on iOS. navigator.storage.persist() is unavailable there, so you cannot opt out of eviction.
How much can I safely store in the share queue?
Read navigator.storage.estimate() before each write and stay well under quota. Chromium grants a large share of free disk, Safari caps origins much lower, and private mode may offer almost none. Enforce a per-entry byte cap and purge terminal entries so the queue cannot grow without bound.