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.


Offline share queue data-flow Diagram showing the flow from user gesture through feature detection, online/offline branch, IndexedDB queue, Background Sync, and the iOS visibilitychange fallback. User taps Share Secure context? No Clipboard / mailto fallback Yes Online now? Yes navigator .share() No IndexedDB Queue (pending) Background Sync (or visibilitychange) flush

Prerequisites

Before initialising the queue, confirm the environment satisfies every hard requirement:

  • HTTPS or localhostwindow.isSecureContext must be true; the share API is blocked on plain HTTP
  • navigator.share present — 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 visibilitychange polling 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-v1shareQueue. Verify entries appear with status: "pending" when offline and are removed or updated after the flush.

Physical device checklist:

  1. Install the PWA on an Android device (Chrome) and an iOS device (Safari).
  2. Enable airplane mode.
  3. Trigger a share — confirm no error is shown to the user and the queue badge or indicator updates.
  4. Re-enable network. On Android, verify Background Sync fires within ~30 seconds. On iOS, switch away from the app and return — verify visibilitychange triggers the flush.
  5. 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 File objects without serialisation: File references become stale after a service worker restart. Always convert to ArrayBuffer plus a name/type record.
  • Not distinguishing AbortError from TypeError: AbortError means the user actively dismissed the share sheet — revert to pending. TypeError means the payload is fundamentally unshareable — mark failed and route elsewhere.
  • Assuming Background Sync is universally supported: iOS Safari has no SyncManager. Hard-coding reg.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.