Background Sync¶
The Background Sync API lets a service worker defer work until the device has connectivity: a page (or the worker) calls registration.sync.register(tag), and the browser later fires a sync event in the service worker, retrying a limited number of times if your handler's promise rejects. It is the standard way to make offline writes (a sent message, a saved form, a "like") reach the server even if the user has closed the tab by the time the network returns. It is implemented only in Chromium-based browsers, so a production design always pairs it with an IndexedDB outbox and a fallback that works everywhere.
Key takeaways
- Background Sync is a signal, not a queue: it carries only a tag. Your data lives in IndexedDB (an outbox), and the
synchandler drains it. - A
syncevent fires as soon as the browser considers itself online afterregister(); if thewaitUntil()promise rejects, Chromium retries after about 5 minutes and again about 15 minutes later. The third attempt hasevent.lastChance === true, and then the registration is dropped. - Chromium gives each
syncevent at most 3 minutes, and "online" means only that some network connection exists, not that your API is reachable. - Registration requires an active service worker and an open top-level window of your origin; the
background-syncpermission is granted by default and never prompts. - Replays can duplicate requests after ambiguous failures, so every queued mutation needs an idempotency key the server honors.
- Firefox and Safari do not implement the API (Mozilla's position is negative), so flush the outbox on launch, on
online, onvisibilitychangeand at worker start-up everywhere. - Workbox's
BackgroundSyncPluginqueues requests only whenfetch()rejects, and its default replay treats any HTTP response, including a 500, as delivered.
Chromium-only
Background Sync ships in Chrome and Edge (desktop and Android), Opera and Samsung Internet. It is not available in Firefox, in Safari on any platform (including installed web apps on iOS and iPadOS), or in Android WebView. Treat it as a progressive enhancement over a design that already works without it.
What Background Sync solves, and what it does not¶
Consider a user who writes a comment on a train, taps Send, and puts the phone away before the tunnel ends. Without a service worker, the request fails and the comment is lost unless the page is still open when connectivity returns. With a service worker but no Background Sync, the worker cannot help either: it is terminated when idle, it gets no event when the network changes, and the online event does not reach it (see Offline UX & Fallbacks). Background Sync closes that gap. It gives the worker a functional event, sync, that the browser dispatches when connectivity allows, even if no page of the site is open anymore.
What it deliberately does not provide:
- No payload. The only data a registration carries is its tag string. Everything you need to replay lives in your own storage, usually IndexedDB.
- No scheduling. You cannot ask for "in ten minutes" or "every hour". One-off sync fires once conditions allow; recurring work is Periodic Background Sync, which has its own install and engagement gates.
- No long transfers. A
syncevent runs inside normal service worker lifetime rules plus a stricter cap, so a 200 MB upload does not belong here. Large transfers that must survive tab closure belong to Background Fetch. - No guarantee. The browser may give up after a few attempts, the user may block the permission, and most engines do not implement it at all.
| Need | Right tool | Why not Background Sync |
|---|---|---|
| Deliver a small write made offline | Background Sync + outbox | This is the use case |
| Refresh content every day for the next launch | Periodic Background Sync | One-off sync fires once, not on a schedule |
| Download a 1 GB video or upload a large file | Background Fetch | Sync events are capped at minutes; Background Fetch runs in the browser's download machinery |
| Server wants to tell the client something now | Push notifications | Sync is client-initiated and needs a prior registration |
| Retry while the page is open | Page-side retry with backoff | Works everywhere; combine it with sync, don't replace it |
Background Sync is one piece of a broader offline-write architecture (conflict handling, optimistic UI, reconciliation). That architecture is covered in Offline-First Data & Sync; this page is about the API, its exact behavior and a production outbox built on it.
The API surface¶
The API adds one manager to ServiceWorkerRegistration, one event to the service worker global scope and one permission name. The Web Background Synchronization specification is a WICG Draft Community Group Report (last published 10 November 2021); it is not on a standards track at the W3C.
partial interface ServiceWorkerRegistration {
readonly attribute SyncManager sync;
};
[Exposed=(Window,Worker)]
interface SyncManager {
Promise<undefined> register(DOMString tag);
Promise<sequence<DOMString>> getTags();
};
partial interface ServiceWorkerGlobalScope {
attribute EventHandler onsync;
};
[Exposed=ServiceWorker]
interface SyncEvent : ExtendableEvent {
constructor(DOMString type, SyncEventInit init);
readonly attribute DOMString tag;
readonly attribute boolean lastChance;
};
dictionary SyncEventInit : ExtendableEventInit {
required DOMString tag;
boolean lastChance = false;
};
registration.sync is available wherever a ServiceWorkerRegistration is: in pages ((await navigator.serviceWorker.ready).sync), in the service worker (self.registration.sync) and, since Chrome 61, in dedicated and shared workers. Like every service worker API it requires a secure context.
SyncManager.register(tag)¶
register(tag) records a sync registration identified by tag on the service worker registration and returns a promise that resolves with undefined once it is stored. It does not wait for the sync to happen. The tag is any string you choose; it names what to sync, and the same tag is how the sync handler recognizes the job.
The spec's algorithm checks, in order:
- The registration must have an active worker. Calling
register()from a page before the first worker activates rejects, which is why page code should go throughnavigator.serviceWorker.ready. - The user must not have disabled background sync for the site.
- At least one top-level or auxiliary window of the origin must exist. Without one, the call is considered "in the background" and rejects. In practice this means you cannot register a brand-new sync from a
pushhandler, aperiodicsynchandler, or asynchandler that runs after every tab closed. - If a registration with the same tag already exists, it is reused (see Registering a tag that already exists); otherwise a new one is created in the pending state.
- If the browser is currently online, a
syncevent is fired for it.
Rejections you will see in Chromium, with the exact messages it uses:
| Condition | DOMException | Chromium message |
|---|---|---|
| No active service worker | InvalidStateError | "Registration failed - no active Service Worker" |
| Background sync blocked in site settings | NotAllowedError | "Permission denied." |
| No top-level window of the origin, or tag longer than 10,240 characters | InvalidAccessError | "Attempted to register a sync event without a window or registration tag too long." |
| Called in a fenced frame | NotAllowedError | "Background Sync is not allowed in fenced frames." |
| Background Sync disabled for the profile, or a storage error | UnknownError | "Background Sync is disabled." |
The 10,240-character limit is kMaxTagLength in Chromium's BackgroundSyncManager. Nobody needs tags that long, but it tells you tags are not the place to smuggle data: store the payload in IndexedDB and keep tags short and stable ("outbox", "outbox:comments").
SyncManager.getTags()¶
getTags() resolves with the tags of every sync registration that still exists for the service worker registration: pending ones, ones waiting for a retry and the one currently firing. A registration disappears after its event succeeds or after its final attempt fails, so getTags() is a quick way to ask "is anything still scheduled?" from DevTools or from a status UI:
(await navigator.serviceWorker.ready).sync.getTags();
// → ["outbox"] while a sync is pending or waiting to retry
// → [] once it has succeeded or given up
It is not a record of your outbox. An empty list with a non-empty outbox is a normal state after Chromium's retries ran out, and it is exactly the case the fallback triggers exist for.
The sync event: tag and lastChance¶
The browser fires sync as a functional event at the registration's active worker, starting the worker if necessary. Your handler should check event.tag, because every registration of every feature arrives through the same event type, and pass a promise to event.waitUntil():
- If the promise fulfills, the sync registration is removed. The browser considers the job done.
- If the promise rejects, or the worker is terminated before it settles (including by a timeout), the browser may schedule another attempt.
- If you never call
waitUntil(), the event completes as soon as the handler returns, so any asynchronous work is unprotected and counts as success.
event.lastChance is true when the browser will not retry this registration after the current attempt. The spec defines it only in those terms ("true if no further attempts will be made after the current attempt"); how many attempts precede it is up to the browser. Use it to switch strategy: keep the data, tell the user, and resolve instead of throwing a failure into the void.
Do not treat lastChance as 'discard the data'
lastChance means the browser is done retrying this registration, not that the mutation is hopeless. The outbox entries stay in IndexedDB, and the next launch of the app can register a fresh sync. Discarding data because Chromium made three attempts within about twenty minutes during a long outage loses user work.
Sync registration states¶
A sync registration moves through four states defined by the spec. Knowing them explains the otherwise surprising behavior around duplicate tags.
stateDiagram-v2
[*] --> pending: register(tag)
pending --> firing: online, fire sync event
firing --> [*]: waitUntil promise fulfilled
firing --> waiting: rejected, not lastChance
firing --> [*]: rejected on lastChance
waiting --> pending: retry delay elapsed
firing --> reregisteredWhileFiring: register(tag) during the event
reregisteredWhileFiring --> pending: event settles either way - pending: ready to fire. It fires when the browser is online; the spec says the browser "SHOULD fire a sync event for each sync registration whose registration state is pending" whenever it changes to online, in any order.
- firing: the event has been dispatched and its
waitUntil()promises have not settled. - waiting: the last attempt failed and a retry is scheduled after a user-agent-defined delay.
- reregisteredWhileFiring: someone called
register()with the same tag while the event was running. When the current attempt settles, success or failure, the registration goes back to pending and fires again. This is the mechanism that makes "enqueue an item while a flush is in progress, then register" safe: the new item cannot be missed because the running flush started before it existed.
Registering a tag that already exists¶
Within one service worker registration, tags are unique, so calling register("outbox") ten times creates one registration. What happens to the existing one differs between the spec and Chromium:
| Existing state | Spec | Chromium |
|---|---|---|
| pending | Unchanged; fires if online | Unchanged |
| firing | Becomes reregisteredWhileFiring | Becomes reregisteredWhileFiring (and the attempt counter resets) |
| waiting (retry scheduled) | Becomes pending and fires immediately if online | Unchanged: the retry delay still applies |
The Chromium behavior (the early return for an identical existing registration in BackgroundSyncManager::RegisterDidAskForPermission) means that calling register() again from a page does not cut a 15-minute retry wait short. If you need an immediate attempt while the page is open, have the page trigger a flush directly, as the outbox code later on this page does with a postMessage.
Tags are also a separate namespace from Periodic Background Sync tags: an origin can have a one-off "news" and a periodic "news" registration at the same time.
How Chromium schedules, retries and limits sync events¶
The spec leaves timing, retry counts and time limits to the browser. Chromium's defaults live in content/public/browser/background_sync_parameters.cc; they can be changed through field trials, but these are the values stable Chrome uses unless an experiment overrides them.
| Parameter | Default | Effect |
|---|---|---|
max_sync_attempts | 3 | Attempts per registration, including the first |
initial_retry_delay | 5 minutes | Delay before the second attempt |
retry_delay_factor | 3 | Multiplier per further attempt: delay = 5 min × 3^(attempt − 1) |
max_sync_event_duration | 3 minutes | Time limit for one sync event |
min_sync_recovery_time | 6 minutes | Minimum delay before waking the browser if it closed mid-sync |
When the first attempt fires¶
For a one-off registration there is no initial delay. Chromium fires the event as soon as two conditions hold: the page's register() promise has resolved (the renderer confirms resolution before the browser may fire), and the network is "sufficient". Chromium's BackgroundSyncNetworkObserver defines sufficient as any connection type other than CONNECTION_NONE. That is the spec's minimal definition of online, "the user agent has established a network connection", and it has consequences:
- A captive portal, a network with no route to your API, or a server that is down all count as online. The event fires and your
fetch()fails or gets an error page. Those failures consume attempts. - If you register while online, the event usually fires within moments, while the page is still open. For many requests Background Sync adds nothing over a direct
fetch()in that case, but it also costs nothing, and it covers the user who closes the tab one second later.
The retry schedule and lastChance¶
When the waitUntil() promise rejects, or the event times out, Chromium increments the attempt counter and computes the next delay from the formula above:
| Attempt | Earliest time | lastChance | If it fails |
|---|---|---|---|
| 1 | Immediately once online | false | Retry scheduled ≥ 5 min later |
| 2 | ≥ 5 min after attempt 1 failed, and online | false | Retry scheduled ≥ 15 min later |
| 3 | ≥ 15 min after attempt 2 failed, and online | true | Registration removed |
The delays are minimums: if the device is offline when a retry becomes due, the attempt waits for connectivity. Chromium sets lastChance when num_attempts == max_attempts - 1, so with the defaults the third attempt is the last. The parameters include a separate max_sync_attempts_with_notification_permission, originally intended to give origins with notification permission more attempts; its default is the same value, 3, so notification permission currently changes nothing.
sequenceDiagram
participant Page
participant Browser
participant SW as Service worker
participant API
Page->>Browser: sync.register("outbox")
Browser-->>Page: promise resolves
Note over Browser: connection present
Browser->>SW: sync (lastChance false)
SW->>API: POST /api/comments
API--xSW: network error
SW-->>Browser: waitUntil rejects
Note over Browser: wait at least 5 min
Browser->>SW: sync (lastChance false)
SW->>API: POST /api/comments
API-->>SW: 503
SW-->>Browser: waitUntil rejects
Note over Browser: wait at least 15 min
Browser->>SW: sync (lastChance true)
SW->>API: POST /api/comments
API-->>SW: 201 Created
SW-->>Browser: waitUntil fulfills, registration removed Two consequences for your design. First, the whole automatic retry budget spans roughly twenty minutes of wall-clock time plus however long the device stays offline in between; a server outage longer than that exhausts it. Second, the browser has no notion of which items failed: one registration covers the whole outbox, and one poisoned entry that always fails would burn all three attempts for everything behind it. The outbox code below separates permanent rejections from transient failures for exactly this reason.
The three-minute limit¶
Chromium dispatches sync with a custom timeout of max_sync_event_duration, 3 minutes, instead of the 5-minute limit that applies to other events (see Service worker lifetime). When it expires, the attempt counts as failed and is retried like a rejection. The spec explicitly allows this ("A user agent MAY impose a time limit on the lifetime extension and execution time of a SyncEvent which is stricter than the time limit imposed for ExtendableEvents in general").
Design the handler to finish well inside that: a per-request timeout (the outbox uses 20 seconds), a total budget below three minutes (150 seconds below), and small batches. Anything that does not fit is left for the next attempt or the next trigger.
What happens when the user closes the browser¶
Sync registrations are stored with the service worker registration in the profile's service worker database, so they survive browser restarts. What happens between restarts depends on the platform:
- Android: Chromium schedules a one-off job with Android's background task scheduler (
BackgroundSyncBackgroundTaskScheduler), persisted across reboots and requiring any network type. The OS wakes Chrome when the job becomes due and connectivity exists, even if the user swiped Chrome away, and Chrome fires the pending events. - Desktop: there is no OS-level wake-up. If the last browser window closes while one-shot sync events are ready to fire, Chromium holds a keep-alive so the browser process stays up until those events have been dispatched (
keep_browser_awake_till_events_completedefaults tofalse, so it does not wait for them to complete). If the browser is not running at all, pending registrations fire the next time it starts and has connectivity.
In both cases the user does not need to open your site again for the event to fire, which is the point of the API. They do need to not have cleared site data: clearing storage or unregistering the service worker deletes sync registrations with it.
Permission and user controls¶
Background Sync is a "default powerful feature" named background-sync. In Chromium it maps to the Background sync site setting (described in Chrome's settings as "After you leave a site, it can keep syncing to finish tasks, like uploading photos or sending a chat message"), which defaults to allow ("Recently closed sites can finish sending and receiving data") and has only allow and block values: the browser never shows a prompt. You can observe it through the Permissions API:
async function backgroundSyncState() {
const registration = await navigator.serviceWorker.ready;
if (!("sync" in registration)) return "unsupported";
try {
const status = await navigator.permissions.query({ name: "background-sync" });
return status.state; // "granted" by default in Chromium, "denied" if blocked
} catch {
return "unknown"; // engines that do not know the permission name throw TypeError
}
}
The try is needed because Firefox and Safari reject unknown permission names with a TypeError. When the user blocks the setting, register() rejects with NotAllowedError; your code should fall back exactly as it does in browsers without the API. See Permissions for the broader model.
Registering from the page and from the service worker¶
You can call register() from either side. The difference is who knows that something needs syncing:
- From the page, right after writing to the outbox. This is the common case, and the page is by definition a top-level window, so the window requirement is satisfied.
- From the service worker, typically in a
fetchhandler that catches a failedPOSTand queues it (the Workbox pattern). Afetchevent for a page request implies an open window, so this works too. - Not from a background-only context. A
sync,periodicsyncorpushhandler that runs with no window open cannot register:register()rejects withInvalidAccessError. That includes re-registering the tag that is currently firing, because Chromium checks for a window before it looks at existing registrations. From a windowlesssynchandler, rejecting the current event is the only way to ask for another attempt.
async function scheduleSync(tag) {
const registration = await navigator.serviceWorker.ready; // waits for an active worker
if (!("sync" in registration)) return false;
try {
await registration.sync.register(tag);
return true;
} catch (error) {
console.warn(`sync.register(${tag}) failed:`, error.name, error.message);
return false;
}
}
Feature-detect on the registration, not on window
"SyncManager" in window works in Chromium, but checking "sync" in registration tests the thing you are about to use, works identically in workers, and cannot be fooled by a browser that exposes the interface without wiring it to registrations.
The outbox pattern: a complete implementation¶
The outbox pattern turns an unreliable network call into a reliable local write followed by eventual delivery:
- The page writes the mutation to an IndexedDB store (the outbox) and updates the UI optimistically. The write is the commit point.
- The page asks for delivery:
sync.register("outbox")where supported, plus a direct nudge to the service worker. - The service worker drains the outbox in creation order, removes what the server accepted, sets aside what it permanently rejected, and stops at the first transient failure.
- The worker reports results to open pages, which reconcile their UI.
flowchart TD
A["User action"] --> B["Outbox.enqueue in IndexedDB"]
B --> C["Optimistic UI: sending"]
B --> D{"sync in registration?"}
D -- yes --> E["sync.register outbox"]
D -- no --> F["postMessage FLUSH_OUTBOX"]
E --> F
E --> G["sync event in service worker"]
F --> H["flush in service worker"]
G --> I["Drain oldest first"]
H --> I
I --> J{"Response class"}
J -- "2xx" --> K["Delete entry, notify page: sent"]
J -- "permanent 4xx" --> L["Move to dead-letter, notify page"]
J -- "network, 5xx, 429" --> M["Record attempt, stop, reject so the browser retries"] The implementation has three files: a storage module shared by page and worker, the page code, and the service worker. It has no dependencies and uses only APIs available in every current engine, so the same code runs where Background Sync is missing; only the trigger differs.
Data model¶
Each outbox entry is a self-contained description of one HTTP request plus bookkeeping:
| Field | Type | Purpose |
|---|---|---|
id | string (UUID) | Primary key and the Idempotency-Key sent to the server |
url, method, headers | string, string, object | Enough to rebuild the request; no Request objects, which are not storable |
body | string, Blob, ArrayBuffer or null | Anything the structured clone algorithm supports |
createdAt | number (ms) | Indexed; drain order is creation order |
attempts, lastError, lastAttemptAt | number, string, number | Diagnostics and UI ("tried 4 times, last error HTTP 503") |
Storing the request description instead of a serialized Request keeps entries inspectable in DevTools, lets a later app version migrate them, and avoids re-reading request bodies. Credentials are not stored: the worker's fetch() sends the origin's cookies at replay time. If your API uses bearer tokens, read the current token at replay time too; a token captured when the user went offline has often expired by the time the network returns.
Shared storage module¶
The module is a classic script that attaches Outbox to self, so the page loads it with <script src="/outbox-db.js"> and the worker with importScripts("/outbox-db.js"). Every operation resolves when its transaction commits, not when the request succeeds.
/* outbox-db.js: shared by the page (<script src="/outbox-db.js">) and the
service worker (importScripts("/outbox-db.js")). Classic script on purpose:
module service workers are not available everywhere the fallback must run. */
(function (global) {
"use strict";
const DB_NAME = "app-outbox";
const DB_VERSION = 1;
const OUTBOX = "outbox";
const DEAD_LETTER = "dead-letter";
let dbPromise = null;
function promisify(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function openDb() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(OUTBOX)) {
const store = db.createObjectStore(OUTBOX, { keyPath: "id" });
// FIFO order: the server sees mutations in the order the user made them.
store.createIndex("byCreatedAt", "createdAt");
}
if (!db.objectStoreNames.contains(DEAD_LETTER)) {
db.createObjectStore(DEAD_LETTER, { keyPath: "id" });
}
};
request.onsuccess = () => {
const db = request.result;
// A newer version of the app wants to upgrade the schema: get out of
// its way instead of blocking it, and reopen lazily next time.
db.onversionchange = () => {
db.close();
dbPromise = null;
};
resolve(db);
};
request.onerror = () => {
dbPromise = null;
reject(request.error);
};
request.onblocked = () => {
console.warn("[outbox] upgrade blocked by an open connection");
};
});
return dbPromise;
}
// Runs `work` in one transaction and resolves when the transaction COMMITS.
// A request can succeed and its transaction still abort (quota, crash), so
// resolving on request success would report writes that never happened.
async function transact(storeNames, mode, work) {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeNames, mode);
let value;
tx.oncomplete = () => resolve(value);
tx.onabort = () =>
reject(tx.error || new DOMException("Transaction aborted", "AbortError"));
work(tx).then(
(result) => {
value = result;
},
(error) => {
try {
tx.abort(); // roll back partial writes; onabort fires next
} catch {
/* the transaction already finished */
}
reject(error);
}
);
});
}
function assertEntry(entry) {
if (!entry || typeof entry.id !== "string" || !entry.id) {
throw new TypeError("outbox entry needs a string id (the idempotency key)");
}
if (typeof entry.url !== "string" || typeof entry.method !== "string") {
throw new TypeError("outbox entry needs url and method");
}
}
const Outbox = {
async enqueue(entry) {
assertEntry(entry);
const record = {
headers: {},
body: null,
attempts: 0,
lastError: null,
createdAt: Date.now(),
...entry,
};
await transact([OUTBOX], "readwrite", async (tx) => {
await promisify(tx.objectStore(OUTBOX).add(record)); // add(): never overwrite
});
return record;
},
// Oldest first, at most `limit` entries.
peekBatch(limit) {
return transact([OUTBOX], "readonly", (tx) =>
promisify(tx.objectStore(OUTBOX).index("byCreatedAt").getAll(null, limit))
);
},
count() {
return transact([OUTBOX], "readonly", (tx) =>
promisify(tx.objectStore(OUTBOX).count())
);
},
remove(id) {
return transact([OUTBOX], "readwrite", (tx) =>
promisify(tx.objectStore(OUTBOX).delete(id))
);
},
update(id, patch) {
return transact([OUTBOX], "readwrite", async (tx) => {
const store = tx.objectStore(OUTBOX);
const current = await promisify(store.get(id));
if (!current) return false; // already sent by another flusher
await promisify(store.put({ ...current, ...patch, id }));
return true;
});
},
// Delete from the outbox and record the rejection atomically, so an entry
// is never in both stores or in neither.
moveToDeadLetter(entry, reason) {
return transact([OUTBOX, DEAD_LETTER], "readwrite", async (tx) => {
await promisify(tx.objectStore(OUTBOX).delete(entry.id));
await promisify(
tx.objectStore(DEAD_LETTER).put({ ...entry, reason, failedAt: Date.now() })
);
});
},
listDeadLetters() {
return transact([DEAD_LETTER], "readonly", (tx) =>
promisify(tx.objectStore(DEAD_LETTER).getAll())
);
},
};
global.Outbox = Outbox;
})(self);
Details that matter in production:
add()instead ofput()inenqueue(): a bug that reuses an id fails loudly (ConstraintError) instead of overwriting an unsent mutation.moveToDeadLetter()touches both stores in one transaction, so a crash between "delete" and "record" cannot lose the entry.onversionchangecloses the connection, so a new app version that bumpsDB_VERSIONis not blocked by an old worker that still holds the database open. See IndexedDB for schema migration patterns.- Storage can be evicted under pressure unless the origin has persistent storage. An outbox holds user data that exists nowhere else, which is the strongest argument for calling
navigator.storage.persist()(see Storage Quotas & Persistence).
Page code¶
/* app.js: page side of the outbox. Loaded after outbox-db.js. */
const SYNC_TAG = "outbox";
// 1. Persist first, then try to deliver. The IndexedDB write is the commit
// point: once it resolves, the mutation survives a crash, a closed tab or
// a killed browser, whether or not Background Sync exists.
async function submitComment(postId, text) {
const entry = await Outbox.enqueue({
id: crypto.randomUUID(), // doubles as the Idempotency-Key header
url: `/api/posts/${encodeURIComponent(postId)}/comments`,
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, clientCreatedAt: new Date().toISOString() }),
});
renderPendingComment(entry); // optimistic UI, marked "sending"
await requestFlush();
return entry.id;
}
// 2. Ask for delivery. With Background Sync the browser also retries after
// the tab closes; the direct nudge covers everything else.
async function requestFlush() {
if (!("serviceWorker" in navigator)) return flushFromPage();
const registration = await navigator.serviceWorker.ready;
let mode = "fallback";
if ("sync" in registration) {
try {
await registration.sync.register(SYNC_TAG);
mode = "background-sync";
} catch (error) {
// NotAllowedError: the user blocked Background sync for this site.
// InvalidAccessError: no top-level window of this origin (or tag too long).
// UnknownError: Background Sync is disabled in this browser profile.
console.warn("[outbox] sync.register() failed; using the fallback", error);
}
}
// Always nudge the worker as well. In Chromium, register() for a tag that is
// waiting out a retry delay does not shorten it, so without the nudge an open
// page could sit on a full outbox for 15 minutes. The worker's Web Lock stops
// the nudge and a concurrent sync event from draining at the same time.
registration.active?.postMessage({ type: "FLUSH_OUTBOX" });
return mode;
}
// Same policy as classify() in sw.js: which HTTP statuses are worth retrying.
const RETRYABLE_STATUSES = new Set([401, 403, 408, 409, 425, 429]);
// Last resort when there is no service worker at all.
async function flushFromPage() {
for (const entry of await Outbox.peekBatch(25)) {
try {
const response = await fetch(entry.url, {
method: entry.method,
headers: { ...entry.headers, "Idempotency-Key": entry.id },
body: entry.body,
credentials: "same-origin",
cache: "no-store",
});
if (response.ok) {
await Outbox.remove(entry.id);
markCommentSent(entry.id);
} else if (response.status >= 500 || RETRYABLE_STATUSES.has(response.status)) {
return; // server trouble or expired session: stop and keep order
} else {
await Outbox.moveToDeadLetter(entry, `HTTP ${response.status}`);
markCommentFailed(entry.id, `HTTP ${response.status}`);
}
} catch {
return; // still offline
}
}
}
// 3. Fallback triggers for engines without Background Sync (Firefox, Safari)
// and for the case where Chromium's three attempts ran out. Each trigger
// is cheap: the service worker exits early when the outbox is empty.
function installFlushTriggers() {
let backoffMs = 5_000;
let timer = 0;
const kick = async () => {
clearTimeout(timer);
if ((await Outbox.count()) === 0) {
backoffMs = 5_000;
return;
}
await requestFlush();
// While the app is open, keep retrying with capped exponential backoff and
// jitter so a fleet of clients does not hammer a recovering server.
const jitter = Math.random() * backoffMs * 0.3;
timer = setTimeout(kick, backoffMs + jitter);
backoffMs = Math.min(backoffMs * 2, 5 * 60_000);
};
window.addEventListener("online", kick);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") kick();
});
kick(); // every launch is a delivery opportunity
}
// 4. Reflect delivery results the service worker reports.
navigator.serviceWorker?.addEventListener("message", (event) => {
const message = event.data || {};
switch (message.type) {
case "OUTBOX_SENT":
markCommentSent(message.id);
break;
case "OUTBOX_REJECTED":
markCommentFailed(message.id, message.reason); // offer edit / discard
break;
case "OUTBOX_STALLED":
showBanner(`${message.remaining} change(s) waiting to be sent.`);
break;
}
});
installFlushTriggers();
// UI stubs: replace with your rendering code.
function renderPendingComment(entry) { document.dispatchEvent(new CustomEvent("comment:pending", { detail: entry })); }
function markCommentSent(id) { document.dispatchEvent(new CustomEvent("comment:sent", { detail: id })); }
function markCommentFailed(id, reason) { document.dispatchEvent(new CustomEvent("comment:failed", { detail: { id, reason } })); }
function showBanner(text) { document.dispatchEvent(new CustomEvent("app:banner", { detail: text })); }
- The page never calls
fetch()for the mutation itself when a service worker exists. Having a single delivery path (the worker'sdrain()) is what makes ordering and de-duplication tractable.flushFromPage()exists only for the rare page that runs without a service worker, and it applies the same status policy as the worker'sclassify(). installFlushTriggers()is the cross-browser part. In Chromium it overlaps with Background Sync, which is harmless: aregister()for an existing pending or waiting tag is a no-op there, and the flush lock stops concurrent drains. It is also what shortcuts Chromium's 5- and 15-minute retry delays while the app is open (through theFLUSH_OUTBOXnudge), and after Chromium's three attempts are used up, it is what re-registers the sync on the next launch.- The backoff loop only runs while a page is open and the outbox is non-empty, and it resets as soon as the outbox drains.
Service worker¶
/* sw.js: delivers the outbox on `sync`, on request from a page, and at
worker start-up in engines without Background Sync. */
importScripts("/outbox-db.js");
const SYNC_TAG = "outbox";
const FLUSH_BUDGET_MS = 150_000; // Chromium cancels a sync event after 3 minutes
const REQUEST_TIMEOUT_MS = 20_000;
const BATCH_SIZE = 20;
self.addEventListener("sync", (event) => {
if (event.tag !== SYNC_TAG) return; // other tags belong to other features
event.waitUntil(handleSync(event.lastChance));
});
self.addEventListener("message", (event) => {
if (event.data?.type !== "FLUSH_OUTBOX") return;
// Best effort: if another flush holds the lock, it will pick our entries up.
event.waitUntil(flushOutbox({ waitForLock: false }).catch(logError));
});
// Engines without Background Sync: every worker start is a delivery chance.
// Not wrapped in waitUntil (there is no event), so it may be cut short; the
// page-side triggers cover that.
if (!("sync" in self.registration)) {
flushOutbox({ waitForLock: false }).catch(logError);
}
async function handleSync(lastChance) {
const { remaining } = await flushOutbox({ waitForLock: true });
if (remaining === 0) return; // resolve: the registration is removed
if (lastChance) {
// No automatic retry follows. Keep the data (it is still in IndexedDB),
// tell open pages, and resolve so the failure is not logged as an error.
await broadcast({ type: "OUTBOX_STALLED", remaining });
await maybeNotify(remaining);
return;
}
// Rejecting is the only way to ask Chromium for another attempt.
throw new Error(`${remaining} outbox entr${remaining === 1 ? "y" : "ies"} pending`);
}
// Serialize flushes across this worker, a new worker being installed, and
// any page running the fallback: two flushers would send duplicates.
async function flushOutbox({ waitForLock }) {
if (!self.navigator.locks) return drain();
return self.navigator.locks.request(
"outbox-flush",
{ ifAvailable: !waitForLock },
async (lock) => (lock ? drain() : { sent: 0, remaining: await Outbox.count() })
);
}
async function drain() {
const deadline = Date.now() + FLUSH_BUDGET_MS;
let sent = 0;
while (Date.now() < deadline) {
const batch = await Outbox.peekBatch(BATCH_SIZE);
if (batch.length === 0) break;
for (const entry of batch) {
if (Date.now() >= deadline) break;
const outcome = await deliver(entry);
if (outcome.kind === "done") {
await Outbox.remove(entry.id);
sent += 1;
await broadcast({ type: "OUTBOX_SENT", id: entry.id, status: outcome.status });
} else if (outcome.kind === "rejected") {
await Outbox.moveToDeadLetter(entry, outcome.reason);
await broadcast({ type: "OUTBOX_REJECTED", id: entry.id, reason: outcome.reason });
} else {
// Transient failure: record it and stop. Later entries may depend on
// this one, and a failing server should not receive the whole queue.
await Outbox.update(entry.id, {
attempts: (entry.attempts || 0) + 1,
lastError: outcome.reason,
lastAttemptAt: Date.now(),
});
return { sent, remaining: await Outbox.count() };
}
}
}
return { sent, remaining: await Outbox.count() };
}
async function deliver(entry) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
let response;
try {
response = await fetch(entry.url, {
method: entry.method,
headers: { ...entry.headers, "Idempotency-Key": entry.id },
body: entry.body,
credentials: "same-origin",
cache: "no-store",
signal: controller.signal,
});
} catch (error) {
// TypeError: offline, DNS, TLS, CORS. AbortError: our timeout. Either way
// the request may or may not have reached the server: the idempotency key
// makes the retry safe.
return { kind: "retry", reason: error.name === "AbortError" ? "timeout" : "network" };
} finally {
clearTimeout(timer);
}
return classify(response);
}
function classify(response) {
const { status } = response;
if (response.ok) return { kind: "done", status };
// 408/425/429: come back later. 409: the same key is still being processed
// (Idempotency-Key draft semantics). 401/403: credentials expired; retrying
// cannot succeed until the user signs in again, but the data must survive.
if ([401, 403, 408, 409, 425, 429].includes(status) || status >= 500) {
return { kind: "retry", status, reason: `HTTP ${status}` };
}
// 400, 404, 410, 413, 422...: replaying the same bytes will never work.
return { kind: "rejected", status, reason: `HTTP ${status}` };
}
async function broadcast(message) {
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
for (const client of windows) client.postMessage(message);
}
async function maybeNotify(remaining) {
if (self.Notification?.permission !== "granted") return;
await self.registration.showNotification("Changes not sent yet", {
body: `${remaining} change(s) will be sent the next time you open the app.`,
tag: "outbox-stalled", // replace, don't stack
});
}
function logError(error) {
console.error("[outbox]", error);
}
Why the worker is built this way:
- One lock for all flushers.
navigator.locks.request("outbox-flush", …)serializes drains across the active worker, a newly installed worker that runs its start-up fallback, and pages. The Web Locks API is available in service workers in all current engines (Chrome 69, Firefox 96, Safari 15.4). Thesyncpath waits for the lock; the opportunistic paths useifAvailable: trueand step aside. - Stop on the first transient failure. If entry 3 fails with a 503, entries 4 to 20 would most likely fail too, and some may depend on entry 3 (an edit to a comment that has not been created yet). Stopping preserves order and does not hammer a struggling server.
- Permanent failures leave the queue. A 422 will fail identically forever. Leaving it at the head of the queue would block everything behind it and burn every retry. The dead-letter store keeps the data so the UI can offer "edit and resend" or "discard".
- Budgeted work. Per-request timeouts (20 s) and a total budget (150 s) keep the event inside Chromium's 3-minute limit. Work left over stays in the outbox, and the rejected promise earns another attempt.
- Resolve on
lastChance. Once there will be no more automatic attempts, rejecting only produces an error in the logs. The worker broadcasts a status message, optionally shows a notification if the origin already has notification permission (asynchandler cannot request it), and resolves.
The response classification encodes a policy; adjust it to your API:
| Response | Class | Reasoning |
|---|---|---|
| 2xx | done | Accepted, or a replay the server recognized from the idempotency key |
| 401, 403 | retry | Credentials expired; the data must survive until the user signs in again |
| 408, 425, 429 | retry | Explicitly "try later"; honor Retry-After if your server sends it |
| 409 | retry | Under the Idempotency-Key draft: the same key is still being processed |
| 5xx | retry | Server-side problem, likely transient |
| Network error or timeout | retry | The request may or may not have reached the server |
| Other 4xx (400, 404, 410, 413, 422) | rejected | The same bytes will never succeed |
Idempotency keys: making replays safe¶
A replay is only safe if sending the same mutation twice has the same effect as sending it once. Background Sync makes duplicates likely, not just possible:
- Ambiguous failures. The request reached the server and was processed, but the response was lost (connection dropped, the 20-second timeout fired, the worker was terminated at the 3-minute mark). From the client's point of view the attempt failed; it will send the request again.
- Multiple flushers. A sync event, a page fallback and a new worker's start-up flush can overlap. The lock prevents most overlap on one device, but not a crash between "server accepted" and "entry deleted".
- Retries after partial progress. A drain that sent 10 of 20 entries before the event timed out resends nothing already deleted, but the one in flight at the timeout is ambiguous.
The standard fix is a client-generated unique key per logical mutation, sent with every attempt, which the server uses to de-duplicate. The outbox reuses the entry's primary key (crypto.randomUUID()) as the Idempotency-Key header. That header name comes from an IETF HTTPAPI working group Internet-Draft (draft-ietf-httpapi-idempotency-key-header); the draft never became an RFC and its last revision (-07, October 2025) has expired, but its semantics are a widely used convention:
| Situation | Server response under the draft |
|---|---|
| First request with a key | Process normally |
| Retry with the same key after completion | Return the stored result of the first request, success or error |
| Retry while the first request is still processing | 409 Conflict |
| Same key reused with a different payload | 422 Unprocessable Content |
| Key missing on an endpoint that requires one | 400 Bad Request |
A server-side implementation for Express with Redis:
// idempotency.mjs: Express middleware implementing Idempotency-Key semantics
// on top of Redis (ioredis). Mount it after express.json() on mutating routes.
import crypto from "node:crypto";
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
// Keep records longer than the client will keep retrying (outbox retention).
const TTL_SECONDS = 7 * 24 * 60 * 60;
const KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
export function idempotent({ required = true } = {}) {
return async (req, res, next) => {
const key = req.get("Idempotency-Key");
if (!key) {
return required
? res.status(400).json({ error: "Idempotency-Key header is required" })
: next();
}
if (!KEY_PATTERN.test(key)) {
return res.status(400).json({ error: "Malformed Idempotency-Key" });
}
// Scope keys per user so one client cannot probe another's results.
const scope = req.user?.id ?? "anonymous";
const storeKey = `idem:${scope}:${key}`;
const fingerprint = crypto
.createHash("sha256")
.update(`${req.method} ${req.originalUrl}\n`)
.update(JSON.stringify(req.body ?? null))
.digest("hex");
// Atomically claim the key. NX: only if nobody has claimed it yet.
const claimed = await redis.set(
storeKey,
JSON.stringify({ state: "in-progress", fingerprint }),
"EX",
TTL_SECONDS,
"NX"
);
if (claimed !== "OK") {
const record = JSON.parse((await redis.get(storeKey)) ?? "null");
if (!record) {
// Expired between SET and GET: ask the client to try again.
return res.status(409).set("Retry-After", "1").json({ error: "Retry" });
}
if (record.fingerprint !== fingerprint) {
return res
.status(422)
.json({ error: "Idempotency-Key was already used with a different request" });
}
if (record.state === "in-progress") {
return res
.status(409)
.set("Retry-After", "5")
.json({ error: "A request with this Idempotency-Key is in progress" });
}
// Replay the stored outcome, success or client error alike.
res.set("Idempotent-Replayed", "true");
return res.status(record.status).type("application/json").send(record.body);
}
let settled = false;
const sendJson = res.json.bind(res);
res.json = (body) => {
settled = true;
const finished =
res.statusCode >= 500
? redis.del(storeKey) // not final: let a retry run the handler again
: redis.set(
storeKey,
JSON.stringify({
state: "done",
fingerprint,
status: res.statusCode,
body: JSON.stringify(body),
}),
"EX",
TTL_SECONDS
);
finished.catch((error) => console.error("idempotency store failed", error));
return sendJson(body);
};
// Handler crashed or the connection dropped before a response: release.
res.on("close", () => {
if (!settled) redis.del(storeKey).catch(() => {});
});
next();
};
}
The Redis record and your business write are two separate systems, so a crash between them can still produce an inconsistent state: the mutation committed but the key was never marked done, so a retry runs it again. For the strongest guarantee, store the idempotency key in the same database transaction as the mutation, with a unique constraint (for example a client_mutation_id column), and treat a unique-violation on insert as "already applied". Natural idempotency is even simpler where it fits: PUT /drafts/{client-generated-id} with the full document is idempotent by construction, and so is "set liked = true" compared with "toggle like".
Never replay non-idempotent requests without a key
Payments, orders, transfers and anything that sends email are the classic duplicates. If the server cannot de-duplicate, do not put the request in a background replay queue at all: keep it in the page, require the user to be online, and show the outcome explicitly.
Workbox: BackgroundSyncPlugin and Queue¶
Workbox's workbox-background-sync module packages a request queue in IndexedDB plus the sync registration. The current release line is Workbox 7 (7.4.1 at the time of writing); Workbox Fundamentals and Advanced Workbox cover the toolkit itself. How the module behaves, from its source:
- Storage: one IndexedDB database named
workbox-background-syncwith an object storerequestsindexed byqueueName. Each entry holds a serialized request (URL, method, headers, body asArrayBuffer, mode, credentials…), atimestampand optionalmetadata. - Tag: each queue registers the sync tag
workbox-background-sync:<queue name>, which is what you type into DevTools to trigger it. Queue names must be unique per origin; creating two queues with the same name throwsduplicate-queue-name. - Retention:
maxRetentionTimeis in minutes and defaults to 7 days (10,080). Expired entries are deleted lazily, whenshiftRequest(),popRequest()orgetAll()encounters them;size()still counts them. - Registration: after
pushRequest()/unshiftRequest(), the queue callssync.register()unless a sync for that queue is in progress, in which case it re-registers when the current one finishes (unless it failed andlastChanceisfalse, because Chromium will retry anyway). - Fallback: when
syncis not inself.registration(orforceSyncFallback: true), the queue calls itsonSynccallback every time the service worker starts, with nowaitUntil(). - Default replay:
replayRequests()shifts entries one by one and callsfetch(). Only a rejectedfetch()puts the entry back and throwsqueue-replay-failed. Any HTTP response, including 500 or 503, counts as success and the entry is gone.
That last point, together with the plugin's trigger, is the main trap:
BackgroundSyncPluginimplements only thefetchDidFaillifecycle callback, which Workbox invokes whenfetch()throws (network failure). A request that reaches the server and gets a 503 is not queued at all.- A queued request that later replays into a 503 is dropped.
For anything beyond "fire and forget", use a Queue with a custom onSync:
// sw.js (bundled): queue failed POSTs with Workbox's plugin.
import { registerRoute } from "workbox-routing";
import { NetworkOnly } from "workbox-strategies";
import { BackgroundSyncPlugin } from "workbox-background-sync";
const commentsQueue = new BackgroundSyncPlugin("comments", {
maxRetentionTime: 24 * 60, // minutes; older entries are dropped at replay time
});
registerRoute(
({ url }) => url.pathname.startsWith("/api/comments"),
new NetworkOnly({ plugins: [commentsQueue] }),
"POST" // routes default to GET; without this the route never matches
);
// sw.js (bundled): a Queue whose replay understands HTTP status codes.
import { Queue } from "workbox-background-sync";
const RETRYABLE = new Set([408, 409, 425, 429]);
const commentsQueue = new Queue("comments", {
maxRetentionTime: 7 * 24 * 60, // the default, stated explicitly
async onSync({ queue }) {
let entry;
while ((entry = await queue.shiftRequest())) {
let response;
try {
response = await fetch(entry.request.clone());
} catch (error) {
await queue.unshiftRequest(entry); // back to the front, order preserved
throw error; // reject the sync event: Chromium retries if it can
}
if (response.status >= 500 || RETRYABLE.has(response.status)) {
await queue.unshiftRequest(entry);
throw new Error(`Replay of ${entry.request.url} got HTTP ${response.status}`);
}
if (!response.ok) {
// Permanent rejection: report it instead of silently dropping it.
await notifyClients({
type: "OUTBOX_REJECTED",
url: entry.request.url,
status: response.status,
metadata: entry.metadata,
});
}
}
},
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "POST") return;
if (!new URL(request.url).pathname.startsWith("/api/comments")) return;
event.respondWith(
(async () => {
const copy = request.clone(); // the body can only be read once
try {
return await fetch(request);
} catch {
// The page set Idempotency-Key; the queued copy carries it too.
await commentsQueue.pushRequest({ request: copy, metadata: { queuedAt: Date.now() } });
return new Response(JSON.stringify({ queued: true }), {
status: 202,
headers: { "Content-Type": "application/json" },
});
}
})()
);
});
async function notifyClients(message) {
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
windows.forEach((client) => client.postMessage(message));
}
Queue options and methods at a glance:
| Member | Description |
|---|---|
new Queue(name, { onSync, maxRetentionTime, forceSyncFallback }) | onSync({ queue }) replaces replayRequests(); maxRetentionTime in minutes (default 7 days); forceSyncFallback ignores native sync and replays on worker start |
pushRequest({ request, metadata?, timestamp? }) | Append to the end and register a sync |
unshiftRequest({ request, metadata?, timestamp? }) | Insert at the front (used to put a failed entry back) |
shiftRequest() / popRequest() | Remove and return the oldest / newest unexpired entry as { request, timestamp, metadata } |
getAll() | All unexpired entries without removing them (expired ones are deleted) |
size() | Number of entries, including expired ones |
replayRequests() | Default replay: re-fetch each entry, stop and throw on the first network error |
registerSync() | Register the queue's sync tag (errors, such as a blocked permission, are swallowed) |
Workbox's queue is per-request and knows nothing about idempotency, so the page should add the Idempotency-Key header itself when it creates the request; the serialized copy then carries it into every replay.
Fallbacks for browsers without Background Sync¶
In Firefox and Safari, and in Chromium when the user blocked the permission or the three attempts ran out, nothing will wake your worker when the network returns. What remains are opportunities you can observe, all of which the outbox code above already uses:
| Trigger | Where | Covers | Caveats |
|---|---|---|---|
| App launch / page load | Page | The user comes back | Nothing happens until they do |
online event | Page | Connectivity restored while open | navigator.onLine === true only means a network interface is up |
visibilitychange to visible | Page | App brought back to the foreground, especially on mobile | Fires often; keep the check cheap |
| Backoff timer | Page | Server outage while open | Stops when the page closes or is frozen |
| Service worker start-up | Worker | Any event that starts the worker (navigation, push) | No waitUntil(), can be cut short; may run in a new worker still installing |
Next successful fetch | Worker | "The network works now" signal | Adds latency to that request if done inline; do it in waitUntil() |
On iOS and iPadOS, where every browser uses WebKit, there is no background execution for web apps apart from push, so "the next time the app is opened" is the delivery guarantee. Design the UI for it: show unsent items clearly, keep them editable, and do not show a "sent" state before the server confirmed. Installed web apps on iOS also keep their own storage separate from Safari's (see iOS & iPadOS).
When a push message arrives, the worker is running and online, which also makes the push handler a delivery opportunity; flushing a small outbox inside event.waitUntil() there is legitimate. Do not send pushes just to wake clients up for this purpose: browsers expect a user-visible notification for pushes (see Push Notifications).
Testing and debugging¶
Trigger sync events from DevTools¶
In Chrome and Edge, open DevTools, go to Application > Service workers, type a tag into the Sync field (it defaults to test-tag-from-devtools) and click Sync. Two details from the DevTools source are easy to miss:
- The button dispatches the event directly through the
ServiceWorker.dispatchSyncEventprotocol method. The tag does not have to be registered, and no registration is created or removed. - It always sets
lastChance: true. A handler tested only with this button never exercises its "reject and retry" branch, and code that discards data onlastChancewill do so on every manual test.
A Periodic sync field next to it does the same for periodicsync (see Periodic Background Sync).
Record what the browser actually did¶
Application > Background services > Background sync records registrations, dispatches, lastChance values and completion status. Recording continues for up to three days even while DevTools is closed, which is the only practical way to observe the 5- and 15-minute retries. Chromium logs "Registered sync", "Dispatched sync event" (with a Last Chance field), "sync event failed" (with the failure reason and Next Attempt Delay (ms)), "Sync event reregistered" and "Sync completed" entries, each keyed by tag.
Simulate offline correctly¶
Two approaches, with different fidelity:
- DevTools offline emulation. In current Chromium, the Offline checkbox (the same global setting in the Service workers pane and in the Network panel's throttling menu) is also applied to the service worker's target, and the Background Sync manager then treats that worker as offline: pending syncs are held, and the Sync button fails, until you turn emulation off. That is convenient for the "offline, then back online" flow.
- A real outage. Workbox's documentation warns against relying on the DevTools checkbox for replay tests, because it historically affected only page requests while the worker's requests still went out. Turning off Wi-Fi, stopping your API server, or pointing the API at a port that refuses connections tests the real path, including the part where Chromium thinks it is online but your server is unreachable.
Automate it¶
Automated tests can drive the same protocol method DevTools uses, with lastChance: false, through a Chrome DevTools Protocol session. With Puppeteer:
// test-sync.mjs: drive a sync event from an automated test (Puppeteer).
import puppeteer from "puppeteer";
const ORIGIN = "http://localhost:8080"; // localhost is a secure context
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.goto(`${ORIGIN}/`);
await page.evaluate(() => navigator.serviceWorker.ready);
const cdp = await page.createCDPSession();
const registrationId = await new Promise((resolve) => {
cdp.on("ServiceWorker.workerRegistrationUpdated", ({ registrations }) => {
const match = registrations.find((r) => r.scopeURL === `${ORIGIN}/` && !r.isDeleted);
if (match) resolve(match.registrationId);
});
cdp.send("ServiceWorker.enable");
});
// Queue a mutation (the test server is configured to fail the first POST).
await page.evaluate(() => window.submitComment("post-1", "queued by the test"));
// Fire a non-final attempt: lastChance false exercises the retry path,
// unlike the DevTools "Sync" button, which always sends lastChance: true.
await cdp.send("ServiceWorker.dispatchSyncEvent", {
origin: ORIGIN,
registrationId,
tag: "outbox",
lastChance: false,
});
await page.waitForFunction(async () => (await window.Outbox.count()) === 0, {
timeout: 10_000,
});
console.log("outbox drained");
} finally {
await browser.close();
}
The parameters are origin, registrationId, tag and lastChance; the registration id comes from the ServiceWorker.workerRegistrationUpdated event after ServiceWorker.enable. For broader strategies (unit-testing handlers outside a browser, mocking IndexedDB) see Automated Testing; for the DevTools panels themselves, Browser DevTools.
Checklist when a sync never fires¶
await (await navigator.serviceWorker.ready).sync.getTags()in the console: is the tag there? If not, the registration already succeeded or gave up, orregister()rejected (check the page console for the error name).- Is the setting blocked? Check the site's permissions (lock icon > Site settings > Background sync) and
navigator.permissions.query({ name: "background-sync" }). - Is the tag compared correctly in the handler? A typo means every event is ignored, and an ignored event with no
waitUntil()counts as success. - Was the event recorded in Background services with a failure? Look at the worker's console for the exception; remember the 3-minute limit.
- Is DevTools offline emulation still on? Pending syncs are held while it is.
Security and privacy considerations¶
The spec's privacy section names two risks, and they explain why only one engine ships the API:
- Location tracking. A sync may run after the user left the site, from a different network, revealing a new IP address to the server. The spec says browsers SHOULD cap retries and event duration; Chromium's 3 attempts and 3-minute limit are that cap.
- History leaking. The DNS lookups and connections of a sync that fires later, on a different network, reveal to that network that the user visited the site.
Mozilla's standards position on Background Sync is negative, citing cross-network tracking and "script execution and resource consumption when it isn't clear to the user that they're interacting with the site", with openness to reconsider given evidence the concerns can be addressed. WebKit's standards-positions entry lists privacy and power concerns without a stated position. See Privacy & Storage Partitioning for how these concerns play out across background APIs.
For your own implementation:
- Replays run with the origin's cookies at replay time. If the user signed out in the meantime, the server must reject the request (401) rather than attribute it to whoever is now signed in; include the user id the mutation was created for in the payload and verify it server-side.
- The outbox is readable by any script on your origin. Do not store secrets there that you would not store in IndexedDB otherwise, and clear it on sign-out after asking the user what to do with unsent items.
- Validate replayed data server-side exactly as if the client sent it live; a queued request is still client input. Service Worker Security covers the wider threat model.
Browser support¶
Support data as of September 2026. For live data, see MDN's SyncManager compatibility table and caniuse: Background Sync API.
| Feature | Chrome / Edge | Firefox | Safari (macOS and iOS) | Samsung Internet | Android WebView |
|---|---|---|---|---|---|
ServiceWorkerRegistration.sync, SyncManager.register(), getTags() | ✅ 49 / ✅ 79 | ❌ | ❌ | ✅ 5.0 | ❌ |
sync event, SyncEvent.tag, SyncEvent.lastChance | ✅ 49 / ✅ 79 | ❌ | ❌ | ✅ 5.0 | ❌ |
SyncManager exposed in dedicated and shared workers | ✅ 61 / ✅ 79 | ❌ | ❌ | ✅ 8.0 | ❌ |
background-sync permission name in permissions.query() | ✅ | ❌ | ❌ | ✅ | ❌ |
Opera supports the API from version 36. Edge versions before 79 (EdgeHTML) never implemented it. Mozilla's position is negative (standards-positions issue 173), and WebKit tracks it as bug 182565 with no implementation. There is no sign that support will broaden, so the fallback path is not a temporary shim: for most of your users it is the implementation.
Common pitfalls¶
- Treating the sync as the queue. The tag carries nothing. Write to IndexedDB first, then register; if you register first and the write fails, you get an event with nothing to send, and if you only keep data in memory, it is gone when the worker stops.
- Forgetting
event.waitUntil(). Without it, the event completes when the handler returns, the registration is removed as successful, and yourfetch()may be killed halfway. - Swallowing errors. A
try/catchthat logs and resolves turns every failure into "success", so Chromium never retries. Reject (throw) when you want another attempt. - Discarding data on
lastChance. It means the browser stopped retrying, not that delivery is impossible. - Ignoring HTTP status.
fetch()resolves for 500s. Both hand-written handlers and Workbox's default replay treat that as delivered unless you checkresponse.ok. - No idempotency. Ambiguous failures cause duplicates. Send a stable key per mutation and de-duplicate on the server.
- One poisoned entry blocks everything. Classify permanent failures and move them out of the queue, or three attempts are wasted on a request that can never succeed.
- Assuming "online" means "reachable". Chromium fires on any connection, captive portals included.
- Long handlers. Chromium stops a
syncevent after 3 minutes and counts it as a failure. Budget the work. - Registering from a windowless context.
register()insidepush,periodicsyncor a backgroundsynchandler rejects withInvalidAccessError. - Expecting
register()to force an immediate retry. In Chromium, re-registering a tag that is waiting for its retry leaves the delay in place. - Shipping without a fallback. Firefox, Safari, WebView and blocked-permission users together are most users on many sites.
Further reading¶
On this site
- Offline-First Data & Sync: conflict resolution, reconciliation and the data architecture around the outbox
- IndexedDB: transactions, schema upgrades and performance
- Periodic Background Sync: recurring background refresh for installed apps
- Background Fetch: large downloads and uploads that outlive the tab
- Messaging & the Clients API: reporting sync results to open pages
- Service Worker Lifecycle: functional events,
waitUntil()and termination - Advanced Workbox: plugins, custom strategies and queues
- Storage Quotas & Persistence: protecting the outbox from eviction
External references
- Web Background Synchronization specification (WICG)
- MDN: Background Synchronization API, SyncManager and SyncEvent.lastChance
- Chrome for Developers: Introducing Background Sync
- Chrome Platform Status: Background Sync API
- Chromium source: background_sync_parameters.cc (retry and timeout defaults)
- Chrome for Developers: workbox-background-sync
- Chrome DevTools: Debug background services
- Microsoft Edge: Synchronize and update a PWA in the background
- IETF draft: The Idempotency-Key HTTP Header Field
- Mozilla standards position on Background Sync and WebKit standards position