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 #
- Try the network first.
submitWithQueuecallsfetch()synchronously on submit; queuing is the exceptional path, not the default one, so the happy-path latency is unchanged. - Capture the idempotency key before anything fails.
crypto.randomUUID()is generated once and reused as both the IndexedDB record’s key and theidempotency-keyheader, so a request that succeeds server-side but times out client-side is never double-processed on replay. - Serialize, don’t reference, the request. The record stores
url,method,headers, and a already-stringifiedbodyas plain data — never theRequestobject itself, since its body stream can only be consumed once. - Register the sync tag after the write commits.
await reg.sync.register('outbox')is only called once the IndexedDBputtransaction’soncompletehas fired, so a queued task is never registered without durable backing data. - Drain in the service worker, not the page.
drainOutbox()runs inside thesyncevent handler so it executes even if the tab that made the original request has since been closed. - 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
syncevent rather than silently dropping the remainder. - Fall back to the
onlineevent whereSyncManagerdoesn’t exist. The page listens foronlineonce and asks the active worker to replay viapostMessage, 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 #
- Simulate the offline submission. Open DevTools → Network, set throttling to “Offline,” and submit the form. Confirm the request appears in Application → IndexedDB →
sync-outbox-v1→requestswith the correcturl,body, andidempotency-key. - Confirm the sync registration. In DevTools → Application → Background Services → Background Sync (Chromium only), verify an
outboxregistration is listed while offline. - 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.
- Force a retry. Return a
500from 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. - Test the fallback path. In Safari or Firefox, repeat step 1–3 and confirm the
onlineevent listener triggerspostMessageand the outbox still drains, proving the app doesn’t depend solely onSyncManager.
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.
Related #
- Background Sync & Request Replay Queues — parent guide covering outbox design, retry backoff, and multi-request ordering
- Offline-First Data Sync & Conflict Resolution — what to do once queued writes can conflict with server state instead of simply replaying once
- Custom Hooks for Async Error Catching — catching the initial fetch rejection in a React component before it ever reaches the outbox