A user edits a note in a subway tunnel where there is no network. A naive implementation would send the PUT immediately and lose the edit: the request failed. A reliable one flips the order: first commit the intent atomically on the client, then deliver it to the server at least once. That is the outbox pattern - a queue of outgoing mutations in durable storage, separate from the data itself. The UI considers the edit saved the moment it lands in IndexedDB together with the outbox record; the network is now a delivery task.
An outbox operation is not a fetch call but a described intent that can be replayed. It has a shape: operationId, a UUID from the client; the entityId of the note; a kind such as note.upsert or note.delete; a payload with the data; baseVersion - the version the edit was made on top of; createdAt; an attempt counter and nextAttemptAt for backoff; and state - pending, sending, conflict or failed. Such a record is self-contained: a worker waking an hour later rebuilds the request from it, without relying on page state.
The key to safe replay is idempotency on the server. The client sends operationId as an Idempotency-Key; the server stores the result of the first processing under that key and, on a repeat, returns the same result rather than performing the operation again. Without this a timeout creates uncertainty: the client does not know whether the command arrived, repeats it - and charges twice or creates a duplicate. The Idempotency-Key turns at least once into exactly one effect and makes a retry safe by definition.
A separate subtlety is the order of several operations on one entity. If the user edited a note twice offline, there are two records in the queue. There are two honest paths: either compact, collapsing several upserts into one final state before sending, or preserve causal order and send in sequence, so a later edit does not land before an earlier one. What you must not do is send them concurrently: without an ordering guarantee you get a race where the outcome depends on which request arrived first.
Now delivery. You want the browser to flush the queue itself when the network returns, even if the tab is closed - that is what Background Sync is for. A one-off sync asks the browser to wake the service worker when connectivity is back: after the durable write the page registers a tag through registration.sync.register, and the worker listens for the sync event. An important caveat: this API is not Baseline, it is absent in some browsers - so it is built through feature detection, with a fallback to sending from the foreground.
Background Sync has its own model, and it tolerates no illusions. The browser, not your code, decides the moment of launch and the retry policy; the worker itself is short-lived and may be stopped between events. Hence the requirements on the handler: it re-reads the durable outbox from IndexedDB rather than relying on worker global variables; it is idempotent, because the event may repeat; and it wraps the work in a Promise inside event.waitUntil, or the browser puts the worker to sleep mid-delivery.
Periodic Background Sync and Background Fetch solve adjacent tasks, but their support is even narrower and their launch conditions even more tightly controlled by the browser. The conclusion is one: none of these mechanisms should be the only way to synchronize. Build on feature detection and always keep a foreground path that flushes the outbox while the app is open. The classic failure is to lean on sync as a guarantee: the user edits a note offline, closes the tab in a browser without the API, and the edit never leaves.
type OutboxOperation = {
operationId: string; // UUID generated by the client
entityId: string;
kind: 'note.upsert' | 'note.delete';
payload: unknown;
baseVersion: number | null;
createdAt: string;
attempt: number;
nextAttemptAt: string;
state: 'pending' | 'sending' | 'conflict' | 'failed';
};// page - after a durable write to the outbox
const registration = await navigator.serviceWorker.ready;
if ('sync' in registration) {
await registration.sync.register('flush-outbox');
} else {
scheduleForegroundFlush();
}
// sw
self.addEventListener('sync', event => {
if (event.tag === 'flush-outbox') {
event.waitUntil(flushOutbox());
}
});