Offline Share Queue Implementation
This guide is part of Permission Flows & Progressive Enhancement.
When navigator.share() is called on a device that has lost its network connection, the browser cannot hand the payload off to a native share target — the call fails immediately with NotAllowedError or AbortError, and the user’s intent is silently discarded. An offline share queue decouples user intent from network availability: the payload is captured in IndexedDB the moment the user taps share, then dispatched through Background Sync the moment connectivity returns — with zero data loss and no perceptible UX change.
This pattern matters most in PWA contexts: field-service workers, news readers, and social apps that operate in patchy mobile coverage. It also applies to any site that uses navigator.share() for user-generated content and cannot afford to lose a share attempt.
Prerequisites
Before initialising the queue, confirm the environment satisfies every hard requirement:
- HTTPS or
localhost—window.isSecureContextmust betrue; the share API is blocked on plain HTTP navigator.sharepresent — not available in Firefox desktop or most in-app browsers- IndexedDB available — present in all modern browsers; check before opening the database
- Service Worker registered — required for Background Sync; must be installed before calling
reg.sync.register() - Background Sync support — available in Chromium ≥ 49 only; iOS Safari and Firefox require the
visibilitychangepolling fallback
Browser Support Snapshot
| Feature | Chrome (Android) | Safari (iOS) | Firefox | Edge |
|---|---|---|---|---|
navigator.share |
61+ | 12.1+ | ✗ desktop, 87+ Android | 17+ |
| Background Sync API | 49+ | ✗ | ✗ | 79+ |
| IndexedDB | All modern | All modern | All modern | All modern |
crypto.randomUUID() |
92+ | 15.4+ | 95+ | 92+ |
The critical gap is Background Sync on iOS Safari. Any production queue must implement the visibilitychange polling fallback — it is not optional.
Step 1 — Feature detection and secure context validation
Validate the environment before touching any storage. This check must precede every call to navigator.share() on this site’s pattern — it is the universal first step in understanding secure context requirements.
/**
* Returns true only if native sharing and queue storage are both viable.
* Call this before opening IndexedDB or registering Background Sync.
*/
export function canUseShareQueue() {
if (!window.isSecureContext) {
console.warn('Share queue requires HTTPS or localhost.');
return false;
}
if (typeof navigator.share !== 'function') {
console.warn('navigator.share not available; routing to fallback.');
return false;
}
if (!('indexedDB' in window)) {
console.warn('IndexedDB unavailable; cannot persist queue.');
return false;
}
return true;
}
Step 2 — Open IndexedDB and define the queue schema
Create the database with a single shareQueue object store. Define a status index to allow efficient queries for pending items, and a createdAt index for TTL-based cleanup.
const DB_NAME = 'share-queue-v1';
const DB_VERSION = 1;
export function openShareDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('shareQueue')) {
const store = db.createObjectStore('shareQueue', { keyPath: 'id' });
store.createIndex('status', 'status', { unique: false });
store.createIndex('createdAt', 'createdAt', { unique: false });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
Step 3 — Serialise payloads and enqueue share intents
File objects are not structured-cloneable across service worker restarts — convert them to ArrayBuffer before storing. Assign a UUID and timestamp for deduplication and TTL management. The status field drives UI feedback (pending, processing, completed, failed).
const MAX_PAYLOAD_BYTES = 5 * 1024 * 1024; // 5 MB per entry
/**
* Serialises a ShareData payload and writes it to IndexedDB.
* @param {ShareData} payload - title, text, url, and optional files array
* @returns {Promise<string>} The UUID of the new queue entry
*/
export async function enqueueSharePayload(payload) {
if (!canUseShareQueue()) {
throw new Error('Share queue prerequisites not met.');
}
// Validate storage quota before writing
if ('storage' in navigator && 'estimate' in navigator.storage) {
const { usage, quota } = await navigator.storage.estimate();
if (usage / quota > 0.85) {
throw new Error('Storage quota critical; cannot enqueue share.');
}
}
const serialisedFiles = payload.files
? await Promise.all(
Array.from(payload.files).map(async (file) => ({
name: file.name,
type: file.type,
buffer: await file.arrayBuffer()
}))
)
: [];
const queueItem = {
id: crypto.randomUUID(),
createdAt: Date.now(),
status: 'pending',
retries: 0,
payload: {
title: payload.title ?? '',
text: payload.text ?? '',
url: payload.url ?? '',
files: serialisedFiles
}
};
const sizeBytes = new Blob([JSON.stringify(queueItem)]).size;
if (sizeBytes > MAX_PAYLOAD_BYTES) {
throw new RangeError(`Payload size (${sizeBytes} bytes) exceeds 5 MB limit.`);
}
const db = await openShareDB();
await new Promise((resolve, reject) => {
const tx = db.transaction('shareQueue', 'readwrite');
const store = tx.objectStore('shareQueue');
const req = store.add(queueItem);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
req.onerror = () => reject(req.error);
});
return queueItem.id;
}
Step 4 — Attempt immediate share or register Background Sync
The controller checks navigator.onLine first. If the device is online, it calls navigator.share() directly. If offline — or if navigator.share() fails with anything other than AbortError — it enqueues the payload and registers a Background Sync tag so the service worker picks it up on reconnect. This is the pattern described in detail on syncing offline share queues with Service Workers.
/**
* Entry point: share immediately if online, otherwise enqueue.
* @param {ShareData} data
*/
export async function shareOrEnqueue(data) {
if (!canUseShareQueue()) {
// Route to clipboard or QR fallback — see /permission-flows-progressive-enhancement/qr-code-generation-for-cross-device-sharing/
throw new Error('Native share unavailable; use fallback.');
}
if (navigator.onLine) {
try {
await navigator.share(data);
return { shared: true, queued: false };
} catch (err) {
if (err.name === 'AbortError') {
// User dismissed the sheet — do not queue
return { shared: false, queued: false, cancelled: true };
}
// TypeError (bad payload) or NotAllowedError: fall through to queue
}
}
const id = await enqueueSharePayload(data);
// Register Background Sync (Chromium only; iOS falls back to visibilitychange)
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const reg = await navigator.serviceWorker.ready;
await reg.sync.register('sync-share-queue');
}
return { shared: false, queued: true, id };
}
Step 5 — Process the queue in the service worker
The service worker sync handler iterates all pending items. It marks each as processing before dispatch to prevent concurrent instances from double-sending. On AbortError (user cancelled the reconstituted share sheet) it reverts the item to pending. On hard failures it increments the retry counter and eventually marks the item failed.
// service-worker.js
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-share-queue') {
event.waitUntil(processShareQueue());
}
});
async function processShareQueue() {
const db = await openShareDB();
const pending = await new Promise((resolve, reject) => {
const tx = db.transaction('shareQueue', 'readonly');
const index = tx.objectStore('shareQueue').index('status');
const req = index.getAll('pending');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
for (const item of pending) {
await updateItemStatus(db, item.id, 'processing');
try {
// Reconstruct File objects from stored ArrayBuffers
const shareData = {
title: item.payload.title,
text: item.payload.text,
url: item.payload.url
};
if (item.payload.files?.length) {
shareData.files = item.payload.files.map(
(f) => new File([f.buffer], f.name, { type: f.type })
);
}
await navigator.share(shareData);
await deleteItem(db, item.id);
} catch (err) {
const MAX_RETRIES = 3;
if (err.name === 'AbortError') {
await updateItemStatus(db, item.id, 'pending');
} else if (item.retries >= MAX_RETRIES) {
await updateItemStatus(db, item.id, 'failed');
} else {
await updateItem(db, {
...item,
status: 'pending',
retries: item.retries + 1,
lastError: err.message
});
}
}
}
}
async function updateItemStatus(db, id, status) {
return new Promise((resolve, reject) => {
const tx = db.transaction('shareQueue', 'readwrite');
const req = tx.objectStore('shareQueue').get(id);
req.onsuccess = () => {
const updated = { ...req.result, status };
tx.objectStore('shareQueue').put(updated);
};
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
}
async function updateItem(db, item) {
return new Promise((resolve, reject) => {
const tx = db.transaction('shareQueue', 'readwrite');
tx.objectStore('shareQueue').put(item);
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
}
async function deleteItem(db, id) {
return new Promise((resolve, reject) => {
const tx = db.transaction('shareQueue', 'readwrite');
tx.objectStore('shareQueue').delete(id);
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
}
Step 6 — Add the iOS Safari visibilitychange fallback
Background Sync is absent on iOS Safari. Register this listener during app initialisation. It fires when the user switches back to the PWA tab with an active connection — the same conditions under which Background Sync would fire on Chromium.
/**
* Fallback for browsers without Background Sync (iOS Safari, Firefox).
* Call once during app initialisation.
*/
export function registerVisibilityFallback() {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && navigator.onLine) {
processShareQueue().catch((err) => {
console.error('Visibility-based queue flush failed:', err);
});
}
});
// Also flush on reconnect
window.addEventListener('online', () => {
processShareQueue().catch((err) => {
console.error('Online-event queue flush failed:', err);
});
});
}
Payload Validation and Error Boundary
Always validate the full payload with navigator.canShare() before calling navigator.share(), including inside the service worker flush. An entry that passes client-side checks may still be rejected if the OS share target list changes between enqueue and dispatch.
export function validateSharePayload(data) {
if (!navigator.canShare) return true; // API absent — let navigator.share surface the error
const valid = navigator.canShare(data);
if (!valid) {
console.warn('navigator.canShare() rejected payload:', data);
}
return valid;
}
Specific DOMException names you must handle:
| Exception | Cause | Recovery |
|---|---|---|
AbortError |
User dismissed the share sheet | Revert to pending; re-prompt on next interaction |
NotAllowedError |
No user gesture, wrong context, or system denial | Mark failed; surface contextual permission prompt |
TypeError |
Payload has no shareable fields, or files list is invalid | Mark failed; route to clipboard fallback |
DataError |
File objects not structured-cloneable | Serialise to ArrayBuffer before storage |
Platform Gotchas
iOS Safari — no Background Sync, no SyncManager:
'SyncManager' in window returns false on all iOS versions as of 2026. The visibilitychange + online fallback is mandatory. Equally important: navigator.share() on iOS requires a direct user gesture at the moment of the call — reconstituting a queued share in a sync event will fail with NotAllowedError. The practical implication is that iOS users must be re-prompted: show a non-intrusive in-app banner (“You have 1 pending share — tap to send”) and call shareOrEnqueue() again from that tap handler.
Android Chrome — gesture requirement in service worker context:
Background Sync fires in the service worker without a live user gesture. On some Android Chrome versions, navigator.share() inside a sync handler will throw NotAllowedError for this reason. The safest approach: use a server-side relay (POST /api/share) from the service worker, and call navigator.share() only from the foreground tab.
Desktop Chrome and Edge:
navigator.share is available but the system share sheet is minimal. Background Sync works correctly. No known quirks with queue flush.
File attachments:
navigator.share({ files }) requires navigator.canShare({ files }) to return true. On devices where it returns false, strip the files array and fall back to text-only sharing or route to the QR code generation pattern for cross-device transfer.
Handling denials that arise during the flush follows the same pattern as handling permission denials gracefully — surface a clear re-engagement path rather than silently dropping the queued item.
Testing and Verification
DevTools — Background Sync:
Open Chrome DevTools → Application → Background Services → Background Sync. Register a sync tag, then go offline in the Network panel, enqueue a share, come back online. The sync event should fire immediately and appear in the log.
DevTools — IndexedDB inspection:
Application → Storage → IndexedDB → share-queue-v1 → shareQueue. Verify entries appear with status: "pending" when offline and are removed or updated after the flush.
Physical device checklist:
- Install the PWA on an Android device (Chrome) and an iOS device (Safari).
- Enable airplane mode.
- Trigger a share — confirm no error is shown to the user and the queue badge or indicator updates.
- Re-enable network. On Android, verify Background Sync fires within ~30 seconds. On iOS, switch away from the app and return — verify
visibilitychangetriggers the flush. - Confirm no duplicate shares appear if the device briefly drops and regains connection multiple times.
Unit testing the queue logic:
Use fake-indexeddb (npm) to run enqueueSharePayload and processShareQueue in Node.js without a real browser. Mock navigator.share to simulate AbortError, NotAllowedError, and success paths, and assert the correct status after each scenario.
Common Pitfalls
- Storing raw
Fileobjects without serialisation:Filereferences become stale after a service worker restart. Always convert toArrayBufferplus a name/type record. - Not distinguishing
AbortErrorfromTypeError:AbortErrormeans the user actively dismissed the share sheet — revert topending.TypeErrormeans the payload is fundamentally unshareable — markfailedand route elsewhere. - Assuming Background Sync is universally supported: iOS Safari has no
SyncManager. Hard-codingreg.sync.register()without a feature check will throw. Always gate it with'SyncManager' in window. - Silent retries without user-visible status: Expose queue depth in the UI (
1 item pending,Syncing…,Share failed — tap to retry). Users who see nothing assume the share worked. - Unbounded queue growth: Enforce a TTL (72 hours is a reasonable default) and check
navigator.storage.estimate()before each enqueue. See the full cleanup implementation in syncing offline share queues with Service Workers.
FAQ
How do I handle file attachments in an offline share queue?
Convert File objects to ArrayBuffer before storing in IndexedDB. Enforce strict size limits (5 MB per entry) to avoid QuotaExceededError. Validate MIME types with navigator.canShare({ files }) before enqueueing. For files exceeding limits, route to a clipboard copy or a cloud-upload fallback instead.
What happens if Background Sync is not supported?
Implement the visibilitychange + online event fallback shown in Step 6. Process the queue when the app regains focus with an active connection, without relying on service worker sync tags. This is the required approach for iOS Safari and Firefox.
How do I prevent duplicate shares when the queue retries?
Assign a unique UUID to each queue entry and check the status field before invoking navigator.share(). Mark items as processing before dispatch to prevent concurrent service worker instances from processing the same entry simultaneously.
Is the Web Share API supported in secure contexts only?
Yes. window.isSecureContext must be true. Always validate HTTPS or localhost before initialising the queue. Insecure contexts must be routed directly to fallback sharing mechanisms — the API will not be present at all.