Skip to content

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 sync handler drains it.
  • A sync event fires as soon as the browser considers itself online after register(); if the waitUntil() promise rejects, Chromium retries after about 5 minutes and again about 15 minutes later. The third attempt has event.lastChance === true, and then the registration is dropped.
  • Chromium gives each sync event 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-sync permission 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, on visibilitychange and at worker start-up everywhere.
  • Workbox's BackgroundSyncPlugin queues requests only when fetch() 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 sync event 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.

Web Background Synchronization (WebIDL)
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:

  1. 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 through navigator.serviceWorker.ready.
  2. The user must not have disabled background sync for the site.
  3. 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 push handler, a periodicsync handler, or a sync handler that runs after every tab closed.
  4. 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.
  5. If the browser is currently online, a sync event 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:

DevTools console
(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_complete defaults to false, 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:

app.js
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 fetch handler that catches a failed POST and queues it (the Workbox pattern). A fetch event for a page request implies an open window, so this works too.
  • Not from a background-only context. A sync, periodicsync or push handler that runs with no window open cannot register: register() rejects with InvalidAccessError. That includes re-registering the tag that is currently firing, because Chromium checks for a window before it looks at existing registrations. From a windowless sync handler, rejecting the current event is the only way to ask for another attempt.
app.js: minimal registration with feature detection
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:

  1. The page writes the mutation to an IndexedDB store (the outbox) and updates the UI optimistically. The write is the commit point.
  2. The page asks for delivery: sync.register("outbox") where supported, plus a direct nudge to the service worker.
  3. 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.
  4. 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
/* 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 of put() in enqueue(): 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.
  • onversionchange closes the connection, so a new app version that bumps DB_VERSION is 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
/* 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 })); }
  1. The page never calls fetch() for the mutation itself when a service worker exists. Having a single delivery path (the worker's drain()) 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's classify().
  2. installFlushTriggers() is the cross-browser part. In Chromium it overlaps with Background Sync, which is harmless: a register() 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 the FLUSH_OUTBOX nudge), and after Chromium's three attempts are used up, it is what re-registers the sync on the next launch.
  3. 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
/* 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). The sync path waits for the lock; the opportunistic paths use ifAvailable: true and 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 (a sync handler 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
// 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-sync with an object store requests indexed by queueName. Each entry holds a serialized request (URL, method, headers, body as ArrayBuffer, mode, credentials…), a timestamp and optional metadata.
  • 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 throws duplicate-queue-name.
  • Retention: maxRetentionTime is in minutes and defaults to 7 days (10,080). Expired entries are deleted lazily, when shiftRequest(), popRequest() or getAll() encounters them; size() still counts them.
  • Registration: after pushRequest()/unshiftRequest(), the queue calls sync.register() unless a sync for that queue is in progress, in which case it re-registers when the current one finishes (unless it failed and lastChance is false, because Chromium will retry anyway).
  • Fallback: when sync is not in self.registration (or forceSyncFallback: true), the queue calls its onSync callback every time the service worker starts, with no waitUntil().
  • Default replay: replayRequests() shifts entries one by one and calls fetch(). Only a rejected fetch() puts the entry back and throws queue-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:

  • BackgroundSyncPlugin implements only the fetchDidFail lifecycle callback, which Workbox invokes when fetch() 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
// 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
// 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.dispatchSyncEvent protocol 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 on lastChance will 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
// 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

  1. await (await navigator.serviceWorker.ready).sync.getTags() in the console: is the tag there? If not, the registration already succeeded or gave up, or register() rejected (check the page console for the error name).
  2. Is the setting blocked? Check the site's permissions (lock icon > Site settings > Background sync) and navigator.permissions.query({ name: "background-sync" }).
  3. 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.
  4. Was the event recorded in Background services with a failure? Look at the worker's console for the exception; remember the 3-minute limit.
  5. 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

  1. 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.
  2. Forgetting event.waitUntil(). Without it, the event completes when the handler returns, the registration is removed as successful, and your fetch() may be killed halfway.
  3. Swallowing errors. A try/catch that logs and resolves turns every failure into "success", so Chromium never retries. Reject (throw) when you want another attempt.
  4. Discarding data on lastChance. It means the browser stopped retrying, not that delivery is impossible.
  5. Ignoring HTTP status. fetch() resolves for 500s. Both hand-written handlers and Workbox's default replay treat that as delivered unless you check response.ok.
  6. No idempotency. Ambiguous failures cause duplicates. Send a stable key per mutation and de-duplicate on the server.
  7. 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.
  8. Assuming "online" means "reachable". Chromium fires on any connection, captive portals included.
  9. Long handlers. Chromium stops a sync event after 3 minutes and counts it as a failure. Budget the work.
  10. Registering from a windowless context. register() inside push, periodicsync or a background sync handler rejects with InvalidAccessError.
  11. Expecting register() to force an immediate retry. In Chromium, re-registering a tag that is waiting for its retry leaves the delay in place.
  12. Shipping without a fallback. Firefox, Safari, WebView and blocked-permission users together are most users on many sites.

Further reading

On this site

External references