Background Sync for Deferred Web Share Actions

This guide is part of Offline-First PWA Patterns for Web Share API.

A share captured while the device is offline is worthless if it evaporates the moment the network flickers or the tab is backgrounded. The Background Sync API is the browser primitive that closes this gap: it lets you register a named task that the browser guarantees to run once connectivity returns — even after the page is closed. This guide covers exactly how to wire that primitive to a share queue, the hard architectural boundary it cannot cross (a service worker can never open a native share sheet), and the polling fallback you must ship for the browsers that lack it.

The diagram below traces a single queued share through its state transitions on a Chromium browser, alongside the iOS Safari branch that replaces the browser-managed sync with foreground polling.

Deferred share state machine with Background Sync and iOS polling fallback A queued share starts in the pending state, moves to sync-registered when a Background Sync tag is registered, transitions to flushing when the browser fires the sync event on reconnect, and reaches completed after a successful server post. A failure returns it to pending for retry. On iOS Safari, where Background Sync is absent, the pending state instead drains through a visibilitychange and online poll into the flushing state. failure → retry with backoff pending in IndexedDB sync-registered tag queued flushing POST /api/share completed deleted register() sync event 200 OK visibilitychange / online foreground poll (iOS Safari) no sync API

Problem Framing

The Background Sync API exists to answer one narrow question: how do I run a task after the user has walked away, once the network comes back? For a share queue, that task is not the native share itself — it is the server-side delivery of the captured intent. Recognising this distinction is the difference between a robust design and one that silently fails.

Without Background Sync, your only options for draining a queue are to hope the user returns to the foreground (so a visibilitychange handler fires) or to keep the tab open and listen for the online event. Both fail the most common real-world case: the user taps share on a train, the connection drops, they lock their phone and put it in their pocket. Foreground listeners never run because the page never regains focus. The queued share sits in IndexedDB indefinitely.

One-off Background Sync solves precisely this. You register a tag while online or offline; the browser persists it and fires a sync event in your service worker the instant it detects a stable connection, waking the worker even if every tab is closed. The catch — and it is a fundamental one — is that a service worker has no document and no user gesture, so it cannot call navigator.share(). The Web Share API requires a secure context and a foreground gesture, which a background context can never satisfy. Background Sync is therefore for server-side share posting and queue bookkeeping: POST the payload to your own endpoint, mark it delivered, purge it. A true native re-share must wait for the foreground, and that is a separate concern handled by your IndexedDB share queue persistence layer when the user next opens the app.

Prerequisites Checklist

Before wiring Background Sync into your share flow, confirm each of the following:

Browser Support Snapshot

Browser / Platform One-off Background Sync Periodic Background Sync Fallback required
Chrome (Android) ✅ v49+ ✅ v80+ (installed PWA) No
Chrome (desktop) ✅ v49+ ✅ v80+ No
Edge (Chromium) ✅ v79+ ✅ v80+ No
Samsung Internet ✅ v5+ ✅ v12+ No
Firefox (all platforms) Yes — polling
Safari (iOS / iPadOS) Yes — polling
Safari (macOS) Yes — polling
In-app WebViews ⚠️ varies Yes — treat as absent

The operational takeaway: Background Sync is a Chromium enhancement. Every design must run correctly with it absent, because roughly half of mobile traffic (all iOS browsers share WebKit) will never see a sync event.

Step-by-Step Implementation

Step 1 — Feature-detect Background Sync and query the permission

Detect support by probing the prototype, not an instance — the property is on ServiceWorkerRegistration.prototype, and checking it avoids awaiting navigator.serviceWorker.ready just to discover the API is missing. Then query the background-sync permission so you can log or branch on an unexpected denied state.

// bg-sync-support.js
export function supportsBackgroundSync() {
  return (
    'serviceWorker' in navigator &&
    typeof ServiceWorkerRegistration !== 'undefined' &&
    'sync' in ServiceWorkerRegistration.prototype
  );
}

export async function backgroundSyncPermission() {
  if (!('permissions' in navigator)) return 'unknown';
  try {
    const status = await navigator.permissions.query({ name: 'background-sync' });
    return status.state; // 'granted' | 'prompt' | 'denied'
  } catch {
    // Some browsers reject unknown permission names — treat as unknown, not denied
    return 'unknown';
  }
}

Run supportsBackgroundSync() once at startup and cache the boolean. Querying the prototype is cheap, but the branch it drives — Background Sync path versus polling path — is a decision you make in exactly one place.

Step 2 — Register the sync tag after enqueue

Registering the tag is only meaningful after the payload is safely in IndexedDB. Registering first risks the browser firing sync against an empty queue if the write fails. Do the write, await its transaction, then register. Wrap the registration so a NotAllowedError (permission blocked) or a browser without the API falls straight through to an immediate foreground attempt when online.

// register-share-sync.js
import { supportsBackgroundSync } from './bg-sync-support.js';

const SYNC_TAG = 'flush-share-queue';

export async function scheduleShareFlush() {
  if (!window.isSecureContext) {
    throw new Error('Background Sync requires a secure context.');
  }

  if (!supportsBackgroundSync()) {
    // No Background Sync (iOS Safari, Firefox) — drain now if we happen to be online
    if (navigator.onLine) await drainQueueInForeground();
    return { scheduled: false, reason: 'unsupported' };
  }

  try {
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register(SYNC_TAG);
    return { scheduled: true };
  } catch (err) {
    // Permission denied or registration quota hit — degrade to foreground drain
    if (navigator.onLine) await drainQueueInForeground();
    return { scheduled: false, reason: err.name };
  }
}

Registering the same tag twice before it fires is a no-op — the browser coalesces duplicate tags — so you may safely call scheduleShareFlush() after every enqueue without accumulating redundant syncs.

Step 3 — Handle the sync event in the service worker

In the worker, listen for sync, match your tag exactly, and hand a queue-draining promise to event.waitUntil(). That call is load-bearing: it keeps the worker alive until the promise settles, and its outcome tells the browser whether to consider the sync complete or to retry. If the promise rejects, the browser retries the tag with its own backoff. Never swallow a genuine network failure into a resolved promise, or you forfeit the retry.

// sw.js — Background Sync handler
self.addEventListener('sync', function onSync(event) {
  if (event.tag === 'flush-share-queue') {
    event.waitUntil(flushShareQueue());
  }
});

async function flushShareQueue() {
  const db = await openShareDB();
  const pending = await getAllByStatus(db, 'pending');

  // A rejection here signals the browser to retry the whole tag later.
  const results = await Promise.allSettled(
    pending.map((entry) => deliverEntry(db, entry))
  );

  const hardFailure = results.some((r) => r.status === 'rejected');
  if (hardFailure) {
    throw new Error('One or more shares failed — request Background Sync retry.');
  }
}

async function deliverEntry(db, entry) {
  await setStatus(db, entry.id, 'processing');
  const res = await fetch('/api/share', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Idempotency-Key': entry.idempotencyKey
    },
    body: JSON.stringify(entry.payload)
  });

  if (res.ok) {
    await deleteEntry(db, entry.id);
    return;
  }
  if (res.status >= 400 && res.status < 500) {
    // Client error — this payload will never succeed; stop retrying it
    await setStatus(db, entry.id, 'failed');
    return;
  }
  // 5xx or network fault — revert and let the sync retry
  await setStatus(db, entry.id, 'pending');
  throw new Error(`Server responded ${res.status}`);
}

Note what this handler does not do: it never calls navigator.share(). It delivers each payload to your own API. When the user later opens the app and you want to trigger a genuine native share sheet, that happens in the foreground with a fresh gesture — the background job’s role is bookkeeping and server delivery only.

Step 4 — Wire the polling fallback for browsers without sync

For iOS Safari and Firefox, replace the browser-managed sync event with foreground triggers: drain when the page becomes visible, when the network returns, and on a modest interval while the tab is active. Guard every path with supportsBackgroundSync() so browsers that do have Background Sync never double-process the queue.

// foreground-fallback.js
import { supportsBackgroundSync } from './bg-sync-support.js';

export function installQueueFallback() {
  if (supportsBackgroundSync()) return; // Chromium handles it in the worker

  const drain = () => {
    if (document.visibilityState === 'visible' && navigator.onLine) {
      drainQueueInForeground().catch((err) =>
        console.error('Foreground drain failed:', err.message)
      );
    }
  };

  document.addEventListener('visibilitychange', drain);
  window.addEventListener('online', drain);
  window.addEventListener('focus', drain);

  // Safety net while the tab stays open and foregrounded
  setInterval(drain, 60_000);
}

export async function drainQueueInForeground() {
  if (!navigator.onLine) return;
  const db = await openShareDB();
  const pending = await getAllByStatus(db, 'pending');
  for (const entry of pending) {
    await deliverEntry(db, entry);
  }
}

This fallback is expanded in fixing Background Sync not firing on iOS Safari, which covers the exact detection gate and the failure modes unique to WebKit.

Step 5 — Decide between one-off and Periodic Background Sync

One-off sync (the sync event) is what a deferred-share queue wants: it fires once, promptly, after you register a tag. Periodic Background Sync (the periodicsync event) fires at browser-chosen intervals and is designed for recurring refreshes — pulling new content, updating a cache — not for one-time delivery. It also demands an installed PWA and the stricter periodic-background-sync permission. Use it only if you additionally want to sweep the queue on a schedule as a belt-and-braces measure.

// periodic-sync.js — optional recurring sweep, installed PWAs only
export async function registerPeriodicSweep() {
  const registration = await navigator.serviceWorker.ready;
  if (!('periodicSync' in registration)) return false;

  const status = await navigator.permissions.query({
    name: 'periodic-background-sync'
  });
  if (status.state !== 'granted') return false;

  await registration.periodicSync.register('sweep-share-queue', {
    minInterval: 12 * 60 * 60 * 1000 // browser treats this as a floor, not a guarantee
  });
  return true;
}

Treat minInterval as a hint. The browser weighs battery, network, and site-engagement signals and may fire far less often than you request, so periodic sync is never a substitute for the one-off tag on the enqueue path.

Payload Validation and Error Boundary

The service worker delivers payloads it did not create, so validate at the boundary where the entry was written, not inside the sync handler. An entry that fails navigator.canShare() in the foreground should never reach the queue. Inside the worker, your only defence is HTTP status branching, because there is no share sheet to reject anything.

// enqueue-guard.js — run in the foreground before persisting
export function assertQueueable(payload) {
  if (!window.isSecureContext) {
    throw new Error('Refusing to queue outside a secure context.');
  }
  const hasContent = payload.text?.trim() || payload.url?.trim() || payload.files?.length;
  if (!hasContent) {
    throw new TypeError('Share payload requires text, url, or files.');
  }
  if (typeof navigator.canShare === 'function' &&
      payload.files?.length &&
      !navigator.canShare({ files: payload.files })) {
    throw new TypeError('File payload rejected by navigator.canShare().');
  }
  return true;
}

The errors you will actually encounter with Background Sync — as opposed to with the share sheet — cluster around registration and delivery:

Error / condition Cause Correct response
registration.sync.register() throws API absent (iOS/Firefox) or permission denied Catch, fall back to foreground drain
sync event never fires No Background Sync support, or connectivity never stabilised Use visibilitychange/online polling
Promise in waitUntil rejects Delivery failed (5xx, network) Let it reject so the browser retries the tag
Duplicate server records Sync retried after a partial success Send X-Idempotency-Key; dedupe server-side
Queue never empties Payload rejected 4xx every attempt Mark failed, stop retrying, surface to the user

For the retry-timing strategy that governs how aggressively your own code re-attempts a failed entry between browser-driven syncs, see retrying failed shares with exponential backoff. That page also contrasts share retries with the distinct permission re-prompt backoff pattern, which throttles how often you ask the user, not how often you post to the server.

Platform Gotchas

iOS and iPadOS — no Background Sync, ever. WebKit has never shipped the API, and because Apple requires all iOS browsers to use WebKit, Chrome and Firefox on iPhone are equally affected. 'sync' in registration is false, and calling register() throws. The foreground fallback from Step 4 is mandatory, not decorative.

Chromium requires connectivity stability, not just onLine. The sync event does not fire the microsecond navigator.onLine flips true. Chrome waits for a connection it judges stable enough to complete a request, which on a flaky mobile signal can mean a delay of seconds to minutes. Do not build UI that promises instant delivery.

Installed PWA versus browser tab. One-off Background Sync works in both a normal tab and an installed PWA on Chromium. Periodic Background Sync, by contrast, only registers for installed PWAs with sufficient site engagement — attempting it from a plain tab silently returns a non-granted permission.

Background restrictions and battery savers. Android’s battery optimisation and “restricted” app states can delay or suppress background syncs for the browser. There is no API to detect this; it is another reason the queue must surface undelivered items rather than assume delivery. Communicating queue state to the user is covered in connectivity-aware share UX.

Tag coalescing hides bugs. Because duplicate tags coalesce, a bug that registers flush-share-queue on every keystroke looks harmless in testing — the browser fires one sync. It becomes visible only when you add a second, differently named tag and discover your handler’s if (event.tag === ...) guard was never exercised. Always match the tag explicitly.

Testing and Verification

Chrome DevTools:

  1. Open Application → Service Workers and confirm your worker is activated and running.
  2. Go to Application → Background Services → Background Sync and click Record. Trigger a share while offline (toggle Offline in the Service Workers panel or the Network conditions drawer).
  3. Confirm a flush-share-queue event is logged with your origin. The panel shows registration, dispatch, and completion timestamps.
  4. Use the Sync context action (or re-enable the network) to dispatch the event manually and watch the worker console. Verify each entry POSTs once and is deleted on 200.
  5. Inspect Application → Storage → IndexedDB to confirm the queue store empties as entries deliver, and that failed entries persist rather than looping.

Physical device checklist:

FAQ

Can navigator.share() run inside a Background Sync handler?

No. navigator.share() requires a visible, foreground document and a fresh user gesture, and a service worker sync handler has neither. The handler can only do gesture-free work — POSTing the payload to your own API, updating IndexedDB status, purging delivered entries. A native share sheet must always be re-invoked from the foreground, which is why this pattern separates delivery (background) from native re-share (foreground).

How is the sync event different from Periodic Background Sync?

One-off Background Sync fires the sync event once, as soon as a stable connection is available after you register a tag — perfect for flushing a queue. Periodic Background Sync fires the periodicsync event at browser-decided intervals for recurring refreshes, requires an installed PWA and the periodic-background-sync permission, and treats your minInterval as a floor rather than a schedule. Use one-off sync for deferred shares and reserve periodic sync for optional housekeeping sweeps.

Why does my sync event never fire on iPhone?

Because iOS Safari does not implement the Background Sync API at all. 'sync' in registration is false and registration.sync.register() throws, so no sync event is ever dispatched. Drain the queue with visibilitychange and online listeners plus a foreground timer instead, as detailed in fixing Background Sync not firing on iOS Safari.

How long will the browser keep retrying a failed sync tag?

If the promise you pass to event.waitUntil() rejects, Chrome retries the tag using its own exponential backoff, typically across a window of up to roughly 24 hours, after which it abandons the tag permanently. Do not rely on this as your delivery guarantee — surface undelivered items in the UI and let the user trigger a manual retry so a burst of failures does not silently discard their shares.

Do I need a permission prompt to use Background Sync?

One-off Background Sync shows no user-facing prompt; the background-sync permission is granted by default in supporting browsers, and you can inspect it with navigator.permissions.query({ name: 'background-sync' }). Periodic Background Sync is stricter — it depends on PWA installation and the site’s engagement score — so always feature-detect and check the permission state before registering a periodic tag.