This section belongs to PWA Offline Recovery & Service-Worker Resilience, which covers how a service worker keeps an application usable β and its writes durable β across dropped connections and process restarts.
The problem: a βsavedβ toast that never reached the server #
A user fills out a form and taps submit while the train goes through a tunnel, or the request simply times out mid-flight. The client-side code that fired the fetch treats the promise rejection as terminal: it shows an error, or worse, it optimistically renders a βSavedβ toast anyway and moves on. Either way, the POST body β the actual state the user was trying to persist β evaporates the instant the request fails. When connectivity returns thirty seconds later, nothing retries it, because nothing remembers it needed to happen.
This is different from a render-time failure that an error boundary can catch, and different from the read-side caching problems covered in Offline-First Data Sync & Conflict Resolution. The failure here is a write that has no durable record outside the JavaScript heap of a tab the user may already have closed. A catch block that logs to console.error and calls it done is not error handling β it is data loss with a stack trace attached.
User-visible consequence: the UI reports success (or a generic, dismissible error) while the mutation itself β the order, the comment, the form submission β simply never lands. Support tickets describe this as βI did save it, I promise,β and they are right.
The fix is to stop treating the failed request as a dead end and instead treat it as a durable task: persist everything needed to replay it, hand scheduling off to a mechanism that survives the tab closing, and only discard the task once the server has actually acknowledged it.
Prerequisites #
Core implementation: a durable replay queue #
The queue schema is shared between two execution contexts: the page enqueues on failure, the service workerβs sync event drains. Everything the replay needs β method, URL, headers, a serialized body, and an idempotency key β is written to IndexedDB before anything is handed off to the browserβs sync scheduler.
// idb-queue.ts β shared between the page and the service worker
const STORE = 'requests';
const MAX_ATTEMPTS = 8;
interface QueuedRequest {
id: string; // idempotency key AND primary key
method: string;
url: string;
headers: Record<string, string>;
body: string | null; // pre-serialized β see Architecture note
attempts: number;
}
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open('replay-queue-db', 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: 'id' });
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function put(record: QueuedRequest): Promise<void> {
const db = await openDb();
db.transaction(STORE, 'readwrite').objectStore(STORE).put(record);
}
async function remove(id: string): Promise<void> {
const db = await openDb();
db.transaction(STORE, 'readwrite').objectStore(STORE).delete(id);
}
// βββ Client: called from a failed fetch's catch block ββββββββββββββββββββββ
export async function enqueueRequest(
input: Omit<QueuedRequest, 'attempts'>
): Promise<void> {
await put({ ...input, attempts: 0 });
const registration = await navigator.serviceWorker.ready;
// Feature-detect: SyncManager only exists in Chromium
if ('sync' in registration) {
await (registration as ServiceWorkerRegistration & {
sync: { register(tag: string): Promise<void> };
}).sync.register('replay-queue');
} else {
// No Background Sync β the client-side drainer takes over (Advanced variant)
window.dispatchEvent(new CustomEvent('replay-queue:enqueued'));
}
}
// βββ Service worker: drains the queue when the browser fires 'sync' ββββββββ
declare const self: ServiceWorkerGlobalScope;
self.addEventListener('sync', (event: SyncEvent) => {
if (event.tag === 'replay-queue') event.waitUntil(drain());
});
async function drain(): Promise<void> {
const db = await openDb();
const all = await new Promise<QueuedRequest[]>((resolve, reject) => {
const req = db.transaction(STORE, 'readonly').objectStore(STORE).getAll();
req.onsuccess = () => resolve(req.result as QueuedRequest[]);
req.onerror = () => reject(req.error);
});
for (const task of all) {
try {
const res = await fetch(task.url, {
method: task.method,
headers: { ...task.headers, 'Idempotency-Key': task.id },
body: task.body,
});
if (res.ok) {
await remove(task.id); // 2xx β the write landed
} else if (res.status === 401 || res.status === 403) {
await remove(task.id); // non-replayable β see Edge cases
deadLetter(task, `auth failure ${res.status}`);
} else if (task.attempts + 1 >= MAX_ATTEMPTS) {
await remove(task.id);
deadLetter(task, 'max attempts exceeded');
} else {
await put({ ...task, attempts: task.attempts + 1 });
throw new Error(`retryable status ${res.status}`);
}
} catch {
// Leaving the record in place is the signal: the browser reschedules
// 'sync' on its own backoff β we do not schedule anything ourselves here.
}
}
}
function deadLetter(task: QueuedRequest, reason: string): void {
// Report via telemetry / persist to a separate dead-letters store β see
// Service Worker Error Telemetry & Debugging for the reporting pipeline.
console.warn('[replay-queue] dead-lettered', task.id, task.url, reason);
}
Architecture note: who owns retry scheduling, and why bodies are strings #
Once registration.sync.register('replay-queue') resolves, the browser β not your application code β owns the retry schedule. It decides when to fire the sync event again based on network quality, connection type, and how long the tag has been pending; there is no API to inspect or override that backoff. Your only lever is the outcome you report back: leaving a record in the IndexedDB store after a failed attempt is what causes the browser to consider the tag βstill pendingβ and reschedule it. Calling event.waitUntil(drain()) is what keeps the service worker alive long enough for that fetch and the surrounding IndexedDB transactions to complete β without it, the browser is free to terminate the worker mid-drain, silently dropping requests that were about to succeed.
Bodies must be serialized before they ever reach IndexedDB, and this is not a stylistic choice. A Request objectβs body is backed by a ReadableStream, and streams are one-shot: once consumed by fetch, the same Request cannot be replayed. IndexedDBβs structured-clone algorithm also cannot durably store a live stream across a worker restart. Serializing to a JSON string (or a base64-encoded string for binary payloads) at enqueue time, and reconstructing the request options at drain time, sidesteps both problems.
Edge cases and gotchas #
| Failure mode | Symptom | Mitigation |
|---|---|---|
| Duplicate delivery | Same order or comment created twice | Idempotency key on every attempt; server deduplicates and returns the original result |
| Non-replayable request | Retrying a request whose auth token has since expired just produces endless 401s | Detect 401/403 explicitly, dead-letter immediately rather than retrying |
| Ordering | Two queued requests for the same resource replay out of order, overwriting a newer write with a stale one | Drain in insertion order (IndexedDB getAll preserves key order) and include a version or timestamp the server can use to reject stale writes |
SyncManager unsupported |
registration.sync is undefined in Safari/Firefox; requests never replay |
Feature-detect 'sync' in registration; fall back to a client online listener draining the same store |
| Unbounded retries | A permanently broken endpoint keeps a task queued forever, growing the store | Enforce MAX_ATTEMPTS and move exhausted tasks to a dead-letter path instead of deleting silently |
| Body already consumed | Re-issuing the original Request object throws TypeError: body stream already read |
Never persist a live Request; persist serialized method/url/headers/body and reconstruct at drain time |
Advanced variant: graceful degradation across browsers #
Because Background Sync is Chromium-only, a production queue needs a second execution path that shares the same IndexedDB schema but drains from the page itself, triggered by the standard online event instead of a browser-scheduled sync tag.
// replay-queue-fallback.ts β client-side drainer for non-Chromium browsers
export async function initReplayQueue(): Promise<void> {
const registration = await navigator.serviceWorker.ready;
const supportsBackgroundSync = 'sync' in registration;
if (supportsBackgroundSync) return; // the SW 'sync' event owns draining
// This tab now owns retry scheduling instead of the browser
window.addEventListener('online', () => { void drainOnClient(); });
if (navigator.onLine) void drainOnClient(); // catch tasks queued while closed
}
async function drainOnClient(): Promise<void> {
const db = await openDb();
const all = await new Promise<QueuedRequest[]>((resolve, reject) => {
const req = db.transaction(STORE, 'readonly').objectStore(STORE).getAll();
req.onsuccess = () => resolve(req.result as QueuedRequest[]);
req.onerror = () => reject(req.error);
});
for (const task of all) {
try {
const res = await fetch(task.url, {
method: task.method,
headers: { ...task.headers, 'Idempotency-Key': task.id },
body: task.body,
});
if (res.ok) await remove(task.id);
else if (task.attempts + 1 >= MAX_ATTEMPTS) await remove(task.id);
else await put({ ...task, attempts: task.attempts + 1 });
} catch {
// Still offline, or the request failed again β stay queued for the
// next 'online' event; no backoff scheduling of our own is added here
// because the browser toggling online/offline is itself the signal.
}
}
}
The two paths β sync.register() and the online listener β read and write the exact same requests store, so a task enqueued on one code path is drained correctly regardless of which browser eventually processes it. This mirrors the retry-queue pattern in Custom Hooks for Async Error Catching, which persists failed operation payloads for in-app retry; the difference here is that the queue must survive the tab closing entirely, not just a component unmounting, which is why IndexedDB and the service worker β rather than an in-memory Map β are the right primitives.
Testing and CI/CD validation #
Manually: open DevTools β Application β Background Sync to see registered tags and force them to fire without waiting for real connectivity, and use the Network panelβs offline throttling to reproduce the initial failure. Both are required to exercise the full path β throttling alone never fires a sync event since there was nothing registered.
For CI, unit-test the drainer against a mocked fetch and assert that a duplicate drain attempt never produces two distinct sends for the same idempotency key:
import { enqueueRequest } from './idb-queue';
describe('replay queue drain', () => {
it('sends the Idempotency-Key header and dedupes across repeated syncs', async () => {
const sentKeys: string[] = [];
global.fetch = jest.fn(async (_url, init: RequestInit) => {
const key = (init.headers as Record<string, string>)['Idempotency-Key'];
sentKeys.push(key);
return new Response(null, { status: 200 }); // server would 200 on replay too
}) as unknown as typeof fetch;
await enqueueRequest({
id: 'order-123',
method: 'POST',
url: '/api/orders',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ qty: 1 }),
});
// Simulate the browser firing 'sync' twice for the same tag
await drain();
await drain();
// The record was deleted after the first 2xx, so the second drain finds nothing
expect(sentKeys).toEqual(['order-123']);
});
it('dead-letters after exceeding the attempt cap instead of retrying forever', async () => {
global.fetch = jest.fn().mockResolvedValue(new Response(null, { status: 503 }));
await enqueueRequest({ id: 'order-456', method: 'POST', url: '/api/orders', headers: {}, body: null });
for (let i = 0; i < 8; i++) await drain();
const remaining = await new Promise((resolve) => {
openDb().then((db) => {
const r = db.transaction('requests', 'readonly').objectStore('requests').get('order-456');
r.onsuccess = () => resolve(r.result);
});
});
expect(remaining).toBeUndefined(); // dead-lettered, not stuck in the store
});
});
Wire the offline-throttling scenario into an end-to-end suite as well: toggle the browser context offline, submit the form, assert the IndexedDB record exists, toggle back online, and assert the record is gone and the server received exactly one request. Route any drain failures the test surfaces into the same pipeline as Service Worker Error Telemetry & Debugging so a systemic replay failure β not just an individual dead-lettered task β pages someone.
For the mechanics of registering the sync tag itself against a real POST endpoint, Queuing Failed POST Requests with the Background Sync API walks through a single concrete form-submission scenario end to end, including the exact Response shapes a typical REST API returns for each branch above.
Frequently asked questions #
Does Background Sync guarantee the request fires the instant connectivity returns? No. The browser controls the retry schedule and may delay or batch sync events based on network quality and battery state. Treat it as an opportunistic replay signal, not a real-time guarantee, and design the UI so the user is told the write is pending rather than promised an exact delivery time.
What happens if the user closes every tab before the sync event fires? The service worker runs independently of any open tab, so a registered sync tag generally survives tab closure and even a browser restart, as long as the underlying service worker registration itself is not unregistered in the meantime.
How do I prevent a replayed request from double-charging or double-submitting? Generate the idempotency key when the request is first enqueued, send it on every replay attempt, and have the server persist processed keys for a bounded window so a duplicate delivery returns the original result instead of re-executing the side effect.
Why doesnβt Safari or Firefox fire the sync event?
The Background Sync API and SyncManager are Chromium-only; neither engine has shipped support. Feature-detect with 'sync' in registration and fall back to the client-side online-event drainer described in the Advanced variant.
How long does the browser keep retrying a registered sync tag?
There is no standardized ceiling β Chromium backs off over minutes to hours and eventually stops if the device stays offline long enough. Enforce your own MAX_ATTEMPTS cap and dead-letter path rather than assuming the browser will expire a tag on a schedule you can predict.
Related #
- PWA Offline Recovery & Service-Worker Resilience β parent section
- Queuing Failed POST Requests with the Background Sync API β a single concrete form-submission walkthrough
- Offline-First Data Sync & Conflict Resolution β reconciling read-side state once the queued writes land
- Custom Hooks for Async Error Catching β an in-memory retry queue for async failures that donβt need to survive a closed tab