# Background Sync API

> How SyncManager.register() defers a failed request until connectivity returns and replays it in the sync event, with exceptions, retry limits, and detection.

import Figure from '@components/Figure.astro';
import backgroundSyncDiagram from '@assets/diagrams/background-sync.svg';

The Background Sync API lets a page register a named *sync* on its service worker registration; when the browser decides the device has connectivity, it fires a `sync` event in the service worker, even if the page that registered it has since closed. It is a Chromium feature (Chrome 49, Edge 79, Samsung Internet 5.0) and is not implemented in Firefox or Safari (BCD `api.SyncManager`), so every retry built on it needs a fallback that runs without it.

<Figure src={backgroundSyncDiagram} alt="Flow diagram of background sync: a request fails offline, the page queues it in IndexedDB and calls sync.register('outbox'); the browser holds the tag until connectivity returns, then fires the sync event in the service worker, which replays the queue inside event.waitUntil(). A resolved promise clears the registration, a rejection with lastChance false is retried later with backoff, and a rejection with lastChance true ends the retries." caption="Background sync: queue, register, replay on the sync event, and the three ways the promise can settle." />

## Syntax

```js
// In the page or in the service worker (needs a window client in Chromium)
const registration = await navigator.serviceWorker.ready;
await registration.sync.register(tag);
const tags = await registration.sync.getTags();

// In the service worker
self.addEventListener('sync', (event) => {
  event.waitUntil(doWork(event.tag, event.lastChance));
});
```

`register()` returns a `Promise<void>` that fulfils once the registration is stored. Registering a tag that is already pending is a no-op, not an error, so a page may call it on every failed request without bookkeeping. `getTags()` returns a `Promise<string[]>` of tags that have not yet fired successfully.

## Parameters

The method takes one string argument; the event exposes two read-only attributes.

| Name | Type | Description |
|---|---|---|
| `tag` | `string` | Identifies the registration. One pending registration exists per tag; the same tag is delivered as `SyncEvent.tag`. Chromium limits the tag to 10,240 characters. |
| `SyncEvent.tag` | `string` | The tag the browser is firing for. Branch on it when one worker handles several queues. |
| `SyncEvent.lastChance` | `boolean` | `true` when the browser will not retry this registration again if the `waitUntil()` promise rejects. Use it to surface the failure to the user instead of retrying. |

## Exceptions

`register()` rejects rather than throwing synchronously.

- `InvalidStateError`: the registration has no active service worker yet. Awaiting `navigator.serviceWorker.ready` before calling `register()` avoids it (spec, "register(tag)" step 3).
- `InvalidAccessError`: in Chromium, `register()` was called from a service worker that has no window client, or the tag exceeds the length limit. Chromium's message is `Attempted to register a sync event without a window or registration tag too long.`.
- `NotAllowedError`: the "Background sync" site permission is blocked. Chromium exposes it at `chrome://settings/content/backgroundSync` and in the per-site permission panel; the rejection message is `Permission denied.`.
- `TypeError`: `registration.sync` is `undefined` in Firefox and Safari, so `registration.sync.register()` throws before any promise exists. Feature-detect with `'sync' in registration`.

A rejected `waitUntil()` promise is not an exception on the page: the browser schedules a retry (Chromium: up to three attempts, five minutes before the second, with a backoff factor of three) and sets `lastChance` to `true` on the final one.

:::observed
In Chrome (English UI), DevTools › Application › Background services › Background sync is empty until the record button (tooltip "Start recording events") is pressed; after that each registration, dispatch, and completion appears as a timestamped row with its origin, service worker scope, and tag, and the recording survives page reloads and tab closure so the delayed retry can be seen. Calling `registration.sync.register('x')` from a service worker with no open window client rejects with `InvalidAccessError: Attempted to register a sync event without a window or registration tag too long.`; the string comes from Chromium's `sync_manager.cc` (linked under "See also").
:::

## Examples

Both examples assume a registered service worker at `/sw.js` whose scope covers the page.

### Queueing a failed POST and replaying it on `sync`

The page writes the outgoing request to IndexedDB (here a minimal `idb-keyval`-style helper is assumed) and registers the tag; the worker drains the queue when the event fires. Keep the queue in storage, not in a worker variable: the browser may terminate the worker between the failed request and the `sync` event.

```js
// page.js
async function sendOrQueue(payload) {
  try {
    await fetch('/api/messages', { method: 'POST', body: JSON.stringify(payload) });
  } catch {
    await outbox.add(payload);                 // IndexedDB-backed queue
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('outbox');
  }
}

// sw.js
self.addEventListener('sync', (event) => {
  if (event.tag !== 'outbox') return;
  event.waitUntil(drainOutbox(event.lastChance));
});

async function drainOutbox(lastChance) {
  for (const item of await outbox.all()) {
    const response = await fetch('/api/messages', { method: 'POST', body: JSON.stringify(item.payload) });
    if (!response.ok && !lastChance) throw new Error(`replay failed: ${response.status}`);
    await outbox.remove(item.id);
  }
}
```

Throwing inside `drainOutbox()` rejects the `waitUntil()` promise, which is what asks the browser for a retry. On the `lastChance` pass the code above stops throwing and clears the queue so the registration ends; a production app would mark those items as failed and show them to the user on the next launch.

### Detecting support and retrying without it

Firefox and Safari expose no `sync` property on the registration. The fallback below retries once on the `online` event, which every browser fires, and otherwise leaves the item for the next page load.

```js
async function deferOrRetry(tag, retryNow) {
  const registration = await navigator.serviceWorker.ready;
  if ('sync' in registration) {
    return registration.sync.register(tag);
  }
  // No Background Sync: retry as soon as the page sees the network return.
  window.addEventListener('online', () => retryNow(), { once: true });
}
```

The fallback only runs while the page is open, which is the capability gap Background Sync closes; state what is lost ("your message will send next time you open the app") rather than hiding it.

## See also

- [Background Synchronization specification: register(tag) method](https://wicg.github.io/background-sync/spec/#dom-syncmanager-register) (wicg.github.io)
- [Introducing Background Sync](https://developer.chrome.com/blog/background-sync) (developer.chrome.com)
- [Chromium source: sync_manager.cc](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/modules/background_sync/sync_manager.cc) (chromium.googlesource.com)
- [Background Sync browser support](/compatibility/background-sync/)
- [Periodic Background Sync API](/reference/service-worker/periodic-background-sync/)
- [Service worker lifecycle](/reference/service-worker/lifecycle/)
- [IndexedDB](/reference/storage/indexeddb/)