Syncing offline share queues with Service Workers

This guide is part of the Offline Share Queue Implementation cluster, which itself sits under Permission Flows & Progressive Enhancement.

When a user triggers navigator.share() while offline, the browser rejects the call before it ever reaches the OS share sheet — there is no network-level error to catch. The correct pattern is to intercept the intent before calling the API, serialise the payload to IndexedDB, and use the Background Sync API to flush the queue once connectivity returns. This page covers the exact wiring: feature detection, IndexedDB schema, Service Worker sync handler, idempotency, TTL expiry, and the visibilitychange fallback required for iOS Safari.

Feature detection gate

Check for both Background Sync and the Service Worker container before registering anything. A missing sync property on ServiceWorkerRegistration means you are on iOS Safari or Firefox — fall back to polling (covered in step 4).

export function supportsBackgroundSync() {
  return (
    'serviceWorker' in navigator &&
    'sync' in ServiceWorkerRegistration.prototype
  );
}

Run this check once on page load and store the result; querying the prototype is cheap but there is no reason to repeat it on every share intent.


Offline share queue sync flow Flow diagram showing how a share intent is intercepted offline, queued in IndexedDB, and later flushed by the Service Worker via Background Sync or a visibilitychange fallback. navigator.share() intent online? (navigator.onLine) navigator.share() direct call IndexedDB queue write SW sync event or visibility poll POST /api/share + idempotency key yes no

Step 1 — Intercept the intent and serialise the payload

Call the gate before invoking navigator.share(). If the device is online, attempt the native call and return on success. If offline — or if the native call fails for a reason other than a deliberate user cancel — convert File objects to ArrayBuffer and write a structured record to IndexedDB. Raw File handles are not structured-cloneable, so storing them directly causes a DataCloneError during the IndexedDB transaction commit.

const SHARE_DB = 'share-queue-v1';
const MAX_PAYLOAD_BYTES = 5 * 1024 * 1024; // 5 MB

export async function queueShareIntent(data) {
  if (!window.isSecureContext) {
    throw new Error('Web Share API requires a secure context (HTTPS or localhost)');
  }

  if (navigator.onLine) {
    try {
      return await navigator.share(data);
    } catch (err) {
      if (err.name === 'AbortError') return; // user dismissed — do not queue
      throw err;
    }
  }

  const { usage, quota } = await navigator.storage.estimate();
  if (usage / quota > 0.8) {
    return { queued: false, reason: 'quota_limit' };
  }

  const serialized = await serializePayload(data);
  if (new Blob([JSON.stringify(serialized)]).size > MAX_PAYLOAD_BYTES) {
    throw new RangeError(`Payload exceeds the ${MAX_PAYLOAD_BYTES}-byte limit`);
  }

  const db = await openShareDB();
  const tx = db.transaction('queue', 'readwrite');
  await tx.store.add({
    id: crypto.randomUUID(),
    payload: serialized,
    createdAt: Date.now(),
    status: 'pending',
    retryCount: 0,
    idempotencyKey: crypto.randomUUID()
  });
  await tx.done;

  if (supportsBackgroundSync()) {
    const reg = await navigator.serviceWorker.ready;
    await reg.sync.register('share-queue-sync');
  }

  return { queued: true };
}

async function serializePayload(data) {
  const clone = { title: data.title, text: data.text, url: data.url };
  if (data.files?.length) {
    clone.files = await Promise.all(
      Array.from(data.files).map(async (file) => ({
        name: file.name,
        type: file.type,
        buffer: await file.arrayBuffer()
      }))
    );
  }
  return clone;
}

Step 2 — Service Worker sync handler with idempotent retry

In the Service Worker, listen for the sync event. Use Promise.allSettled so one failing item does not abort the rest. Stamp each item as processing before the network call; if the call fails, revert to pending so Background Sync retries on the next connectivity window. Items already marked processing (from a concurrent worker instance) are skipped.

self.addEventListener('sync', (event) => {
  if (event.tag === 'share-queue-sync') {
    event.waitUntil(processShareQueue());
  }
});

async function processShareQueue() {
  const db = await openShareDB();
  const pending = await db.getAllFromIndex('queue', 'status', 'pending');

  await Promise.allSettled(
    pending.map(async (item) => {
      await db.put('queue', { ...item, status: 'processing' });

      try {
        const res = await fetch('/api/share', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-Idempotency-Key': item.idempotencyKey
          },
          body: JSON.stringify(item.payload)
        });

        if (!res.ok) {
          if (res.status >= 500) throw new Error(`Server error ${res.status}`);
          // 4xx — unrecoverable for this item
          await db.put('queue', { ...item, status: 'failed' });
          return;
        }

        await db.delete('queue', item.id);
      } catch {
        // Network error — Background Sync will retry
        await db.put('queue', {
          ...item,
          status: 'pending',
          retryCount: item.retryCount + 1
        });
      }
    })
  );
}

Step 3 — TTL expiry to prevent unbounded IndexedDB growth

Undelivered items accumulate silently. A createdAt index lets you purge records older than 72 hours in a single cursor pass — run this before each sync cycle or on app start.

export async function purgeStaleQueue() {
  const db = await openShareDB();
  const cutoff = Date.now() - 72 * 60 * 60 * 1000;

  const tx = db.transaction('queue', 'readwrite');
  let cursor = await tx.store.index('createdAt').openCursor(
    IDBKeyRange.upperBound(cutoff)
  );
  while (cursor) {
    await cursor.delete();
    cursor = await cursor.continue();
  }
  await tx.done;
}

Step 4 — iOS Safari polling fallback

iOS Safari does not implement Background Sync. Rather than routing through a QR code fallback or an SMS/email fallback, the lightest fix is a visibilitychange listener that triggers the queue flush whenever the app returns to the foreground with a live connection.

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible' && navigator.onLine) {
    purgeStaleQueue()
      .then(() => processShareQueueClient())
      .catch(console.error);
  }
});

// Client-side mirror of the SW handler — used when SW sync is unavailable
async function processShareQueueClient() {
  if (supportsBackgroundSync()) return; // SW will handle it
  const db = await openShareDB();
  const pending = await db.getAllFromIndex('queue', 'status', 'pending');
  for (const item of pending) {
    try {
      await db.put('queue', { ...item, status: 'processing' });
      const res = await fetch('/api/share', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Idempotency-Key': item.idempotencyKey
        },
        body: JSON.stringify(item.payload)
      });
      if (res.ok) await db.delete('queue', item.id);
      else await db.put('queue', { ...item, status: 'pending' });
    } catch {
      await db.put('queue', { ...item, status: 'pending' });
    }
  }
}

Failure modes and recovery

Error Symptom Fix
DataCloneError on IndexedDB write Queue write throws during tx.done Convert FileArrayBuffer before storing; never store DOM references
QuotaExceededError on IndexedDB write Silent failure or thrown exception Check navigator.storage.estimate() before each write; run purgeStaleQueue() proactively
Duplicate share on retry Server receives the same payload twice Always include X-Idempotency-Key; implement server-side deduplication keyed on that header
processing items never clear Worker crash mid-dispatch; items stuck On next startup, reset items in processing status that have a createdAt older than 5 minutes back to pending
Main-thread freeze during serialisation UI jank when queuing large files Offload serializePayload to a Worker or gate it behind requestIdleCallback

Browser and platform caveat

Background Sync is supported in Chromium-based browsers (Chrome 49+, Edge 79+) on Android and desktop. Firefox has not shipped Background Sync. iOS Safari (including all iOS browsers, which share the same engine) does not support Background Sync — the visibilitychange polling path in step 4 is the only reliable option there. When handling permission denials gracefully, this same iOS detection pattern applies: never assume a missing API means the user denied a permission rather than the browser simply not implementing the feature.

FAQ

Why does navigator.share() throw NotAllowedError offline instead of NetworkError?

The Web Share API validates the user gesture and secure context before attempting OS routing. When offline, the browser cannot hand off to native share targets, so it rejects with NotAllowedError or AbortError. Intercepting the call client-side before it reaches the API is the correct fix.

How do I prevent duplicate shares during Background Sync retries?

Attach a unique X-Idempotency-Key header to each share request and implement server-side idempotency — return 200 OK for duplicate keys. Mark payloads as processing in IndexedDB before dispatch to prevent concurrent Service Worker instances from re-queuing them.

What is the recommended maximum payload size for an offline share queue?

Keep individual payloads under 5 MB to avoid QuotaExceededError and main-thread serialisation lag. For larger files, store only metadata in the queue and re-fetch the blob from a local cache, or re-prompt the user when online.

How can I debug a stuck Background Sync event in Chrome DevTools?

Navigate to Application > Background Services > Background Sync. Verify the tag matches your registration and use the manual Sync button to trigger the event while offline. Check the Service Worker console for unhandled promise rejections and inspect the network tab for CORS issues or 5xx responses.