This page zooms into one mechanism inside Background Sync & Request Replay Queues, part of the broader PWA Offline Recovery & Service-Worker Resilience coverage: durably queuing a single failed POST and replaying it once the device is back online.

The exact failure this page solves #

A user submits a comment form while their laptop is on a flaky train connection. fetch() rejects with a TypeError: Failed to fetch. The app shows a generic error toast, the comment text is gone from the input, and the user has no way to know whether the server received it. Nothing was persisted, so the submission is lost the moment the tab reloads or the user navigates away.

The fix is to treat a failed POST as a durable task, not a transient error: serialize the request into an IndexedDB “outbox” store, register a one-shot sync tag with the Background Sync API, and let the service worker drain the outbox the instant the browser regains connectivity — even if the tab that submitted the form has since closed. This is narrower than general offline-first data sync and conflict resolution: there’s no merge logic here, just guaranteed at-least-once delivery of a single write.

Zero-to-working implementation #

The client attempts the request immediately and only falls back to queuing on failure. The service worker owns the actual replay, so the queue survives tab closure.

// client/submitWithQueue.ts
const DB = 'sync-outbox-v1';
const STORE = 'requests';

async function openOutbox(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB, 1);
    req.onupgradeneeded = () => {
      req.result.createObjectStore(STORE, { keyPath: 'id' });
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

export async function submitWithQueue(url: string, body: unknown): Promise<void> {
  const idempotencyKey = crypto.randomUUID();
  const record = {
    id: idempotencyKey,
    url,
    method: 'POST',
    headers: { 'content-type': 'application/json', 'idempotency-key': idempotencyKey },
    body: JSON.stringify(body),
  };

  try {
    const res = await fetch(record.url, { method: record.method, headers: record.headers, body: record.body });
    if (!res.ok) throw new Error(`server rejected: ${res.status}`);
    return; // delivered on the first try, nothing to queue
  } catch {
    const db = await openOutbox();
    await new Promise<void>((resolve, reject) => {
      const tx = db.transaction(STORE, 'readwrite');
      tx.objectStore(STORE).put(record);
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });

    const reg = await navigator.serviceWorker.ready;
    if ('SyncManager' in window) {
      await reg.sync.register('outbox');
    } else {
      // Safari / Firefox: no SyncManager — replay on the next 'online' event instead
      window.addEventListener('online', () => reg.active?.postMessage({ type: 'replay-outbox' }), { once: true });
    }
  }
}
// sw.js — drains the outbox whenever the browser fires the sync event
self.addEventListener('sync', (event) => {
  if (event.tag === 'outbox') event.waitUntil(drainOutbox());
});

// Fallback path for browsers without SyncManager (message posted from the page)
self.addEventListener('message', (event) => {
  if (event.data?.type === 'replay-outbox') event.waitUntil(drainOutbox());
});

async function drainOutbox() {
  const db = await new Promise((resolve, reject) => {
    const req = indexedDB.open('sync-outbox-v1', 1);
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });

  const records = await new Promise((resolve, reject) => {
    const req = db.transaction('requests', 'readonly').objectStore('requests').getAll();
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });

  let anyFailed = false;
  for (const record of records) {
    try {
      const res = await fetch(record.url, { method: record.method, headers: record.headers, body: record.body });
      if (!res.ok) throw new Error(`retryable status: ${res.status}`);
      // Success — remove it so the record is never replayed twice
      const tx = db.transaction('requests', 'readwrite');
      tx.objectStore('requests').delete(record.id);
    } catch {
      anyFailed = true; // leave it in the store for the next sync attempt
    }
  }
  // Throwing tells the browser to reschedule the sync event with backoff
  if (anyFailed) throw new Error('outbox drain incomplete; will retry');
}

Step-by-step explanation #

  1. Try the network first. submitWithQueue calls fetch() synchronously on submit; queuing is the exceptional path, not the default one, so the happy-path latency is unchanged.
  2. Capture the idempotency key before anything fails. crypto.randomUUID() is generated once and reused as both the IndexedDB record’s key and the idempotency-key header, so a request that succeeds server-side but times out client-side is never double-processed on replay.
  3. Serialize, don’t reference, the request. The record stores url, method, headers, and a already-stringified body as plain data — never the Request object itself, since its body stream can only be consumed once.
  4. Register the sync tag after the write commits. await reg.sync.register('outbox') is only called once the IndexedDB put transaction’s oncomplete has fired, so a queued task is never registered without durable backing data.
  5. Drain in the service worker, not the page. drainOutbox() runs inside the sync event handler so it executes even if the tab that made the original request has since been closed.
  6. Delete on success, throw on failure. Each record is deleted individually as soon as its replay returns a 2xx; if any record in the batch still fails, the function throws so the browser reschedules the whole sync event rather than silently dropping the remainder.
  7. Fall back to the online event where SyncManager doesn’t exist. The page listens for online once and asks the active worker to replay via postMessage, giving Safari and Firefox the same eventual-delivery guarantee through a different trigger.

Edge cases #

Scenario Symptom Mitigation
Storing the live Request object TypeError: body stream already read on replay Read and store body as a string/ArrayBuffer before the first fetch attempt; reconstruct a fresh Request/fetch call from the stored fields each time
Duplicate delivery Server processes the same comment twice after a retry Send a stable idempotency-key header generated once per record; have the server de-duplicate by that key regardless of how many times the outbox replays it
Browser lacks SyncManager reg.sync is undefined; registering throws Feature-detect 'SyncManager' in window before calling .register(); use the online event + postMessage fallback shown above for Safari and Firefox
Auth token expires between queue and replay Replay returns 401, record is treated as retryable forever Treat 401/403 as non-retryable: delete the record, and surface a re-authentication prompt instead of throwing, so the sync event doesn’t loop indefinitely against an expired token

Verification steps #

  1. Simulate the offline submission. Open DevTools → Network, set throttling to “Offline,” and submit the form. Confirm the request appears in Application → IndexedDB → sync-outbox-v1requests with the correct url, body, and idempotency-key.
  2. Confirm the sync registration. In DevTools → Application → Background Services → Background Sync (Chromium only), verify an outbox registration is listed while offline.
  3. Go back online and watch the drain. Switch Network back to “Online” (or “No throttling”). Within a few seconds the sync event should fire; refresh the IndexedDB view and confirm the record was deleted, then check your backend logs for exactly one processed submission.
  4. Force a retry. Return a 500 from the backend once, resubmit offline, go online, and confirm the record is not deleted (the handler threw) and that a second sync attempt eventually succeeds once the backend recovers.
  5. Test the fallback path. In Safari or Firefox, repeat step 1–3 and confirm the online event listener triggers postMessage and the outbox still drains, proving the app doesn’t depend solely on SyncManager.

Frequently asked questions #

Why can’t I just store the original Request object in IndexedDB?

A Request body stream can only be read once. If you store the live Request and the first read fails, replaying it later throws TypeError: body stream already read. Clone the body into a plain string or ArrayBuffer before writing the record, then reconstruct a fresh Request from those fields on each replay attempt.

What happens if the sync event fires but I still have no network?

Throwing inside the sync handler tells the browser the attempt failed, and it reschedules the event with an exponential backoff of its own, up to a browser-defined retry ceiling. You don’t need to implement your own backoff timer for this path — just make sure a failed fetch() results in a thrown error rather than a swallowed rejection.

Does the Background Sync API work in Safari or Firefox?

No. As of this writing, SyncManager is Chromium-only; Safari and Firefox never expose 'SyncManager' in window. The online-event fallback in this guide is not optional polish — it’s the only replay path those browsers ever take, so test it independently of the sync-event path.