Offline-First PWA Patterns for Web Share API
Service workers, IndexedDB, and the Background Sync API give PWAs the infrastructure to capture user intent at the moment it happens — even when the network does not cooperate. The guides in this section cover the complete offline architecture for applications built around the Web Share API: caching strategies, share-queue persistence, sync orchestration, and the platform-specific quirks that determine whether a deferred share actually reaches its destination.
Two constraints shape every decision here: navigator.share() requires a secure context and an explicit user gesture, and the Background Sync API is absent on iOS Safari — which means every sync design must carry a polling fallback.
Architecture Overview
Every offline-capable share feature rests on four mandates:
- Secure context first —
navigator.share()and Background Sync both requirewindow.isSecureContext === true. Validate this before rendering any share UI. - Feature-detect, never assume — check
navigator.share,navigator.canShare(payload), andServiceWorkerRegistration.syncindependently; each may be absent. - Progressive enhancement — the share button must work when none of the above exist, routing to clipboard copy,
mailto:, orsms:fallbacks. - Payload validation before persistence — normalise and validate
title,text,url, and file MIME types before writing to IndexedDB to prevent stale or unshare-able entries from accumulating in the queue.
Service Worker Caching Strategies
A service worker is the gateway to offline capability. Before any share queue logic can run, the worker must be registered, activated, and holding the assets the PWA needs to function without a network. The full strategy catalogue — cache-first, network-first, and stale-while-revalidate routing per resource type — is covered in Service Worker Caching Strategies for Share-Capable PWAs.
Cache-first vs. network-first
Static assets (CSS, JS, icons) benefit from cache-first: serve from the cache immediately, update in the background. HTML pages that render dynamic content need network-first with a cache fallback: attempt a fresh fetch, serve the cached version on failure.
// sw.js — Install: pre-cache shell assets
const CACHE_NAME = 'share-pwa-v1';
const SHELL_ASSETS = ['/', '/assets/css/main.css', '/assets/js/main.js'];
self.addEventListener('install', function onInstall(event) {
event.waitUntil(
caches.open(CACHE_NAME).then(function (cache) {
return cache.addAll(SHELL_ASSETS);
})
);
self.skipWaiting();
});
// sw.js — Fetch: network-first for HTML, cache-first for assets
self.addEventListener('fetch', function onFetch(event) {
const isHTML = event.request.headers.get('accept')?.includes('text/html');
if (isHTML) {
event.respondWith(
fetch(event.request)
.then(function (response) {
const clone = response.clone();
caches.open(CACHE_NAME).then(function (cache) {
cache.put(event.request, clone);
});
return response;
})
.catch(function () {
return caches.match(event.request);
})
);
return;
}
event.respondWith(
caches.match(event.request).then(function (cached) {
return cached || fetch(event.request);
})
);
});
Service worker registration with update detection
Register the service worker from your app’s main script and listen for the waiting state to prompt users when a new version is available.
// main.js — Service worker registration
export async function registerServiceWorker() {
if (!('serviceWorker' in navigator) || !window.isSecureContext) return;
try {
const registration = await navigator.serviceWorker.register('/sw.js');
registration.addEventListener('updatefound', function () {
const incoming = registration.installing;
if (!incoming) return;
incoming.addEventListener('statechange', function () {
if (incoming.state === 'installed' && navigator.serviceWorker.controller) {
// A new version is waiting — prompt the user to refresh
notifyUserOfUpdate();
}
});
});
} catch (err) {
console.error('Service worker registration failed:', err.message);
}
}
IndexedDB Share Queue
When navigator.onLine is false — or when the network request fails — the user’s share intent must be captured locally. IndexedDB provides the durable, structured storage that survives page reloads and service worker restarts. For schema design, versioned migrations, transaction correctness, and quota management, see IndexedDB Persistence for Offline Share Queues.
Schema design
Each queue entry carries a UUID, timestamp, status flag, retry count, and a normalised payload. Files must be converted to ArrayBuffer before storage — raw File references become stale after a service worker restart.
// share-db.js — Open (or create) the share queue database
const DB_NAME = 'share-queue';
const DB_VERSION = 1;
export function openShareDB() {
return new Promise(function (resolve, reject) {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = function (event) {
const db = event.target.result;
if (!db.objectStoreNames.contains('queue')) {
const store = db.createObjectStore('queue', { keyPath: 'id' });
store.createIndex('status', 'status', { unique: false });
store.createIndex('timestamp', 'timestamp', { unique: false });
}
};
req.onsuccess = function (event) { resolve(event.target.result); };
req.onerror = function (event) { reject(event.target.error); };
});
}
Enqueueing a share payload
Validate the payload structure with navigator.canShare() before persisting — an entry that the platform can never share is wasted storage.
// share-queue.js — Add a validated payload to the queue
export async function enqueueShare(payload) {
if (!window.isSecureContext) {
throw new Error('Share queue requires a secure context.');
}
// Pre-validate payload schema before storage
if (typeof navigator.canShare === 'function' && !navigator.canShare(payload)) {
throw new TypeError('Payload rejected by navigator.canShare()');
}
const entry = {
id: crypto.randomUUID(),
timestamp: Date.now(),
status: 'pending',
retries: 0,
payload: {
title: payload.title ?? '',
text: payload.text ?? '',
url: payload.url ?? ''
}
};
const db = await openShareDB();
return new Promise(function (resolve, reject) {
const tx = db.transaction('queue', 'readwrite');
const req = tx.objectStore('queue').add(entry);
req.onsuccess = function () { resolve(entry.id); };
req.onerror = function () { reject(req.error); };
});
}
Processing the queue
When connectivity is restored, iterate pending entries, attempt to share each one, and update their status. Differentiate between AbortError (user cancelled — revert to pending) and hard errors (mark as failed after MAX_RETRIES).
// share-queue.js — Flush pending entries
const MAX_RETRIES = 3;
export async function processShareQueue() {
if (!navigator.onLine) return;
const db = await openShareDB();
const pending = await getAllByStatus(db, 'pending');
for (const entry of pending) {
await updateStatus(db, entry.id, 'processing');
try {
await navigator.share(entry.payload);
await updateStatus(db, entry.id, 'completed');
} catch (err) {
if (err.name === 'AbortError') {
// User dismissed the dialog — leave it for next attempt
await updateStatus(db, entry.id, 'pending');
} else if (entry.retries >= MAX_RETRIES) {
await updateStatus(db, entry.id, 'failed', err.message);
} else {
await incrementRetry(db, entry.id);
}
}
}
}
function getAllByStatus(db, status) {
return new Promise(function (resolve, reject) {
const tx = db.transaction('queue', 'readonly');
const idx = tx.objectStore('queue').index('status');
const req = idx.getAll(status);
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error); };
});
}
function updateStatus(db, id, status, lastError) {
return new Promise(function (resolve, reject) {
const tx = db.transaction('queue', 'readwrite');
const store = tx.objectStore('queue');
const get = store.get(id);
get.onsuccess = function () {
const entry = { ...get.result, status };
if (lastError) entry.lastError = lastError;
const put = store.put(entry);
put.onsuccess = function () { resolve(); };
put.onerror = function () { reject(put.error); };
};
get.onerror = function () { reject(get.error); };
});
}
function incrementRetry(db, id) {
return new Promise(function (resolve, reject) {
const tx = db.transaction('queue', 'readwrite');
const store = tx.objectStore('queue');
const get = store.get(id);
get.onsuccess = function () {
const entry = { ...get.result, status: 'pending', retries: (get.result.retries || 0) + 1 };
const put = store.put(entry);
put.onsuccess = function () { resolve(); };
put.onerror = function () { reject(put.error); };
};
get.onerror = function () { reject(get.error); };
});
}
Background Sync Integration
The Background Sync API lets the browser defer a tagged operation until the device has connectivity — even if the tab is closed. Register a sync tag from the foreground page and handle it in the service worker. The complete pattern, including the iOS fallback and retry design, lives in Background Sync for Deferred Web Share Actions.
Registering the sync tag
// foreground — request a sync after enqueueing
export async function requestShareSync() {
if (!('serviceWorker' in navigator)) return;
const registration = await navigator.serviceWorker.ready;
if ('sync' in registration) {
await registration.sync.register('flush-share-queue');
} else {
// Background Sync unavailable — process queue immediately if online
if (navigator.onLine) {
await processShareQueue();
}
}
}
Handling the sync event in the service worker
// sw.js — sync handler
self.addEventListener('sync', function onSync(event) {
if (event.tag === 'flush-share-queue') {
event.waitUntil(processShareQueue());
}
});
event.waitUntil() keeps the service worker alive until the promise settles. If the promise rejects, the browser will retry the sync — honouring the exponential backoff defined in the Implementing Exponential Backoff for Permission Re-prompts pattern, which applies equally to queue retries.
iOS Safari Fallback: Polling on visibilitychange
iOS Safari does not implement the Background Sync API. The reliable alternative is to listen for visibilitychange events and process the queue whenever the page becomes visible and the device is online.
// foreground — iOS-compatible queue drain
document.addEventListener('visibilitychange', async function onVisibilityChange() {
if (document.visibilityState !== 'visible') return;
if (!navigator.onLine) return;
try {
await processShareQueue();
} catch (err) {
console.error('Queue processing failed on visibility:', err.message);
}
});
// Also drain on network restoration
window.addEventListener('online', async function onOnline() {
try {
await processShareQueue();
} catch (err) {
console.error('Queue processing failed on reconnect:', err.message);
}
});
This mirrors the polling strategy described in the Syncing Offline Share Queues with Service Workers guide, which covers the full cross-platform compatibility matrix.
Connectivity Detection and UX Feedback
navigator.onLine is a coarse signal — it reflects whether the device has a network interface active, not whether the server is actually reachable. A device can report online while behind a captive portal or on a congested mobile connection that drops all requests. The UX layer that turns these signals into trustworthy user feedback is detailed in Connectivity-Aware UX for Offline Sharing.
Connectivity probe
Supplement navigator.onLine with a lightweight HEAD request to a cacheable endpoint:
// connectivity.js — probe-based online check
export async function isConnected(probeUrl = '/manifest.json') {
if (!navigator.onLine) return false;
try {
const res = await fetch(probeUrl, {
method: 'HEAD',
cache: 'no-store',
signal: AbortSignal.timeout(3000)
});
return res.ok;
} catch {
return false;
}
}
Surfacing queue state to the user
Expose the number of pending shares visibly. Silent retries erode trust — users who shared content while offline need confirmation that their intent was captured.
// ui.js — update badge or status text
export async function updateQueueBadge(badgeEl) {
const db = await openShareDB();
const pending = await getAllByStatus(db, 'pending');
if (pending.length === 0) {
badgeEl.hidden = true;
return;
}
badgeEl.hidden = false;
badgeEl.textContent = `${pending.length} share${pending.length === 1 ? '' : 's'} queued`;
badgeEl.setAttribute('aria-label', `${pending.length} pending share${pending.length === 1 ? '' : 's'} waiting for connectivity`);
}
Progressive Enhancement Decision Tree
Use this ordered checklist to route each share attempt correctly:
For unsupported or denied contexts, route through the QR Code Generation for Cross-Device Sharing fallback, which works entirely in-browser without any network or permission dependency.
Cross-Browser Behaviour Notes
| Browser | Background Sync | navigator.share |
navigator.canShare |
Offline queue notes |
|---|---|---|---|---|
| Chrome (Android) | Yes (v49+) | Yes | Yes | Full offline queue with Background Sync works as documented |
| Chrome (desktop) | Yes (behind flag) | Yes (v89+) | Yes (v89+) | Desktop share sheet limited; Background Sync rarely useful |
| Safari / iOS | No | Yes (v15.1+) | Yes (v15.1+) | Must use visibilitychange + online polling fallback |
| Firefox (Android) | Partial (v79+) | No | No | No native share; clipboard fallback only |
| Firefox (desktop) | No | No | No | Clipboard and mailto: are the only viable paths |
| Edge (Chromium) | Yes | Yes | Yes | Inherits Chrome behaviour on all platforms |
| Samsung Internet | Yes | Yes | Yes | Background Sync reliable; test with large file payloads |
Key insight: Safari on iOS is the highest-traffic case for mobile PWAs and it has no Background Sync. Treat the visibilitychange poll as the primary sync mechanism and Background Sync as an enhancement for Chrome/Edge.
Error Handling Reference
| DOMException name | Trigger | Recovery |
|---|---|---|
NotAllowedError |
No user gesture, or browser policy blocks the share | Never invoke navigator.share() outside a synchronous click handler; check gesture freshness |
AbortError |
User dismissed the native share dialog | Treat as soft cancellation — revert queue entry to pending; do not retry automatically |
TypeError |
Payload structure is invalid (missing required fields, unsupported files MIME type) |
Validate with navigator.canShare() first; strip files and retry text/url-only payload |
DataError |
File object serialisation failed in IndexedDB | Convert File to ArrayBuffer via file.arrayBuffer() before writing to the store |
QuotaExceededError |
IndexedDB storage quota exhausted | Purge completed and failed entries on each queue drain; enforce per-entry size cap |
For the complete DOMException handling strategy including contextual UI recovery, see Handling Permission Denials Gracefully.
Designing Contextual Offline Share UI
The pre-share UI must communicate that the action will be queued when offline — not silently swallowed. Follow the Designing Contextual Permission Prompts approach: explain what will happen before the action is committed.
Recommended pattern:
- Detect offline state before the user taps the share button.
- Show a tooltip or inline notice: “You’re offline — your share will be sent when you reconnect.”
- On tap, enqueue the payload and confirm via a toast: “Saved for sharing — 1 item queued.”
- When the device goes online, process the queue silently and confirm with a success toast.
- If sharing fails after reconnect (e.g.,
NotAllowedErrorbecause too much time passed since the gesture), surface a manual “Retry share” button rather than an error.
FAQ
Does navigator.share() work from within a service worker?
No. navigator.share() requires a visible user gesture from a foreground browsing context. A service worker cannot invoke it. The share queue pattern works around this: the service worker triggers a sync event that wakes the foreground page, which then calls navigator.share() with the queued payload.
How long will the browser retry a Background Sync tag if it keeps failing?
Browsers implement their own retry schedules, typically with exponential backoff up to a maximum dwell time (often 24 hours in Chrome). If the sync tag consistently rejects, the browser will eventually stop retrying. Design your queue to surface failed entries to users rather than relying on indefinite browser retries.
Can I share files (images, PDFs) through an offline queue?
Yes, but with extra steps. Convert each File to an ArrayBuffer using await file.arrayBuffer() before storing in IndexedDB. When processing the queue, reconstruct the File from the buffer with the original name and type. Always validate the reconstructed payload with navigator.canShare({ files: [reconstructedFile] }) before invoking navigator.share(), since file-sharing support varies by OS.
What is the IndexedDB storage quota for PWAs?
Quota is calculated per origin and varies by browser and device. Chrome allocates up to 60% of available disk space; Safari limits origins to 1GB. Use the navigator.storage.estimate() API to check current usage and warn users when the queue is approaching the limit. Purge completed and aged failed entries regularly.
How do I test offline queue behaviour in DevTools?
In Chrome DevTools, open the Application tab and use the Service Workers panel to toggle “Offline”. Set navigator.onLine to false and trigger your share flow. Inspect IndexedDB under Storage → IndexedDB to verify entries are written. Use Application → Background Services → Background Sync to simulate a sync event and confirm the queue drains correctly.
Related
- Service Worker Caching Strategies for Share-Capable PWAs — cache-first, network-first, and stale-while-revalidate routing for the offline share UI
- IndexedDB Persistence for Offline Share Queues — object-store schema, versioned migrations, and file payload storage
- Background Sync for Deferred Web Share Actions — sync tags, the iOS polling fallback, and retry design
- Connectivity-Aware UX for Offline Sharing — probes, network status, and queued-share feedback
- Offline Share Queue Implementation — complete IndexedDB queue implementation with deduplication and status tracking
- Syncing Offline Share Queues with Service Workers — cross-platform sync with Background Sync and iOS polling fallback
- Permission Flows & Progressive Enhancement — the full permission architecture this offline layer integrates with
- Web Share API & Security Contexts — secure context requirements, feature detection patterns, and payload validation
- Handling Permission Denials Gracefully — recovery patterns for denied or failed share attempts
- QR Code Generation for Cross-Device Sharing — network-free fallback that works regardless of connectivity or share API support