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.
Deciding What the Queue Promises
Before any of the mechanics matter, an offline share flow has to decide what it is actually promising, because the honest answer differs by platform and the interface has to say so.
On Chromium the promise can be strong. A registered sync tag means the browser will run your handler once connectivity returns, with the tab closed and the app in the background, so “we will send this for you” is literally true. The delay is unbounded and the browser may throttle it, but the delivery does not depend on the user coming back.
On iOS the promise has to be weaker, because there is no background sync event at all. The queue moves only while a page is running, which means every delivery is triggered by the user returning to the app. “We will send this next time you open the app” is the accurate sentence, and it is a better one to ship than a background promise the platform will not keep.
That distinction shapes more than the copy. It decides how aggressively the queue should be pruned, because entries live longer on a platform that drains less often. It decides whether a queued-count badge is worth showing, since on iOS it is the only prompt the user will get. And it decides how much a share is worth queueing at all: a payload that will sit for a week is a payload that will be evicted, which is why keeping the stored bytes small matters more here than anywhere else on the site.
Everything below assumes that decision has been made explicitly rather than inherited from whichever platform the feature was first tested on.
What the Queue Is Actually For
It is worth naming the goal precisely, because a share queue is easy to over-build and easy to under-specify.
The goal is not to guarantee delivery. Nothing on the web can promise that: the device may never regain connectivity, the browser may evict the origin, the user may uninstall the app. The goal is narrower and achievable — never lose the user’s intent to a condition they cannot see. A tap on Share while offline should have exactly the same immediate outcome as a tap while online: the content is captured, the user is told, and they move on.
That framing settles several design arguments at once.
It settles when to confirm. The confirmation belongs after the write commits, because the write is the thing being promised. Confirming on the tap makes the message a prediction, and predictions can be wrong in ways the user never finds out about.
It settles what to store. Enough to reconstruct the share and nothing else. A payload kept small is a payload that survives eviction, fits inside a constrained quota, and drains quickly when connectivity returns. Where the content is large, a link to it is a better queue entry than the bytes.
It settles how long to keep trying. Indefinitely for the intent, briefly for the attempt. A pending entry should outlive many failed sends, but a specific attempt should back off and stop rather than hammering a server that has already refused it four times.
And it settles what to show. A count of pending shares is the single most useful piece of state the queue exposes, because it is the only way a user learns that something is still outstanding. On a platform without background delivery it is also the prompt that gets the queue drained at all.
Everything in the guides below — the schema, the indexes, the retry schedule, the caching strategy, the quota handling — exists to serve that one promise. When a design decision is unclear, the question that resolves it is usually: does this make it more or less likely that a captured intent eventually reaches its destination?
Reading Order for These Guides
Start with the queue itself. The persistence guide defines the schema, the indexes and the transaction boundary that everything else assumes, and most of the subtler bugs in this area are schema bugs wearing a different hat.
Read the connectivity guide next, because it determines when the queue moves at all. It is also the guide that corrects the single most common wrong assumption in offline work — that navigator.onLine means anything about reachability.
Background sync and caching can be read in either order. Sync decides how the queue drains when the app is closed, on the platforms where that is possible; caching decides whether the interface that shows the queue is even available offline. Both compete for the same storage budget, which is why the quota guide is worth reading before either of them rather than after the first eviction.
A Note on Scope
Everything in this section concerns shares the user initiated and the app could not deliver immediately. It deliberately does not cover general background synchronisation of application state, which is a larger problem with different trade-offs around conflict resolution and ordering.
Keeping the queue narrow — one intent per entry, no cross-entry dependencies, no merge semantics — is what makes it small enough to reason about, cheap enough to store, and safe to drain from two contexts at once. A queue that grows into a general sync engine acquires all of those problems and loses the property that made it dependable in the first place.
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