JavaScript Background Sync Table
| Piece | What it does | Field note |
|---|---|---|
registration.sync.register(tag) | The one-shot ask | a string tag, deduplicated by tag, persisted across restarts; needs an active service worker or it rejects |
the sync event (tag, lastChance) | The SW delivery | fires on the browser schedule - seconds to hours; the tag is the only payload, everything else lives in IndexedDB |
event.waitUntil(promise) | The keep-alive | the browser holds the worker until the promise settles; reject to signal failure and earn a backoff retry |
IndexedDB outbox | The payload store | write the request (URL, method, body, idempotency key) before registering; the event carries nothing but the tag |
idempotency key | The dedup contract | delivery is at-least-once in practice - the server dedupes on the key so double-fire is safe |
periodicSync.register(tag, {minInterval}) | The refresh variant | installed-and-engaged PWAs only; the interval is a floor, the browser picks the windows |
getTags() | The pending list | what is still queued; a tag that never fires means the SW failed to activate, not that sync broke |
the online event + drain | The fallback path | Firefox and Safari have no sync - share one drain function between the sync handler and the foreground reconnect |
Background Synchronization is the API for deferred work: register a sync now, let the browser fire a sync event in your service worker later - when the network is back, when the device is awake, when the browser's heuristics say the moment is right. The use case it was built for is the offline mail outbox: the user hits send in a tunnel, you queue the message in IndexedDB, register sync('outbox'), and the SW sends it whenever connectivity returns - even if the tab died seconds after the tap. It is the difference between an app that loses the send and an app that quietly completes it.
Bottom line: the API is two moving parts and a contract between them. In the page: await registration.sync.register(tag) - a string label, deduplicated by tag, persisted across browser restarts. In the service worker: self.addEventListener('sync', event => event.waitUntil(handleSync(event.tag))) - the browser holds the worker alive until that promise settles. The contract is delivery, not immediacy: the event may fire in ten seconds or ten hours (Chrome's heuristics weigh network quality, battery, and your site's engagement), and your code must be able to do the work with nothing but the tag - everything else (the queued message, the auth context) lives in IndexedDB or Cache Storage.
The integration reality: background sync is Chromium-only on the one-shot variant, needs an installed service worker with a fetch-ish scope, and its sync event gives you a tag - nothing else. The mature pattern is a transactional outbox: write the payload to IndexedDB first, register the sync, and on sync fire read-until-empty from the queue, deleting items only after a confirmed 2xx; if the send fails, throw inside waitUntil so the browser reschedules with backoff. Periodic background sync (periodicSync) is the second variant - a refresh-style timer the browser grants only to installed PWAs the user actually engages with - and it solves feed pre-fetching, not outbox draining. Pair the API with push (server-initiated wake) and you have the full background work surface: push for react, sync for retry.
How to use
- Ensure the service worker exists: await navigator.serviceWorker.ready - background sync registers against a SW registration; without an active worker (controlled page, scope installed) register() rejects.
- Queue first, register second: await idbOutbox.add(message) then await reg.sync.register('outbox-send') - the tag is your work item name; registering the same tag twice before it fires replaces, not duplicates, so batch by tag.
- Handle the event in the SW: self.addEventListener('sync', event => { if (event.tag === 'outbox-send') event.waitUntil(drainOutbox()) }) - lastChance fires when the browser is about to stop trying: event.lastChance tells you to persist progress and stop throwing.
- Drain until empty and fail loudly: read items one by one, fetch(), delete each item only after response.ok - and throw on failure; a rejected waitUntil promise tells the browser the sync failed so it retries with exponential backoff (up to the browser's cap).
- Probe the optional tiers: 'sync' in registration and 'periodicSync' in registration - periodicSync.register('feed-refresh', {minInterval: 12*3600*1000}) only sticks for installed PWAs with engagement; guard every call and degrade to foreground retry on Firefox/Safari.
Frequently asked questions
When exactly does the sync event fire, and why might it be hours late?
The spec is deliberately silent about timing - delivery is guaranteed-ish, the schedule is the browser's. Chrome fires when: the network is reachable and judged decent (the Network Information heuristics plus a probe), the device is not in deep doze, and the site has some engagement credit (a recently open tab helps; an installed PWA helps more). In practice: page open and network up - seconds; flaky mobile data - whenever connectivity stabilizes, which can be minutes; device in airplane mode overnight - whenever the radio returns, possibly at next unlock or charge. Three consequences for design. First, never build anything latency-sensitive on sync: confirmation UI must come from the foreground attempt, sync is the safety net. Second, your payload store must be self-describing - the event carries only event.tag, so 'outbox-send' must be enough to find the work in IndexedDB. Third, test the failure path, not the happy path: the retry loop (throw, backoff, event.lastChance) is the code that runs in production; register-and-forget code that assumes instant fire produces apps that silently drop sends for hours and look broken when they finally arrive. There is also a hard cap: after enough failed retries (Chrome abandons after roughly a few days of backoff), the sync is dropped for good - your outbox needs a foreground fallback sweep (drain on next page load) or items can strand.
How do I structure the outbox so nothing is lost or double-sent?
Treat it as a transaction log, not a to-do list. Write phase (foreground, online or offline): generate an idempotency key (uuid), store the full request - URL, method, headers, body - in IndexedDB with state 'pending', then register the sync tag. Fire phase (sync event in SW): read the oldest pending item, replay the exact stored request with the idempotency key as a header, and only on response.ok (or 4xx-that-is-permanent, decide per endpoint) delete the item and advance; on network failure throw so the browser backs off and redelivers. The idempotency key is what makes the inevitable double-fire safe: sync events have at-least-once semantics in practice (browser crash mid-drain, retry after ambiguous timeout), so the server deduplicates on the key instead of you trying to make delivery exactly-once. Ordering: drain serially (await in a loop), not Promise.all - chat and mail protocols care about order, and parallel replays of the same outbox interleave badly. Large payloads: store Blobs in IndexedDB and build the body at send time. And clean up the tags: after the queue empties, nothing fires again for that tag - no explicit unregister exists for sync, which is fine because an empty drain is a no-op; just do not re-register the same tag for new work until the drain finished, or the dedup rule will swallow your second message. The mirror-image mistake is registering per-item tags ('outbox-<uuid>') - you lose batching and the tag namespace fills with garbage; one tag plus a queue is the pattern.
What is the difference between one-shot sync, periodicSync, and push - and which do I use?
Three different triggers for the same SW. One-shot sync (reg.sync.register): user-intent-shaped retry - it fires after a page gesture queued work, best-effort, Chromium-only, no permission prompt beyond the SW itself; use for outbox, analytics batches, draft saves. Periodic sync (reg.periodicSync): browser-scheduled refresh on a minInterval you propose - but it is granted only to installed PWAs with real engagement, fires within browser-chosen windows (the interval is a floor, not a schedule), and Chrome gates it behind a permission prompt plus heuristics; use for feed/cache pre-warming on installed apps, never for anything the user is waiting on. Push: server-initiated - your server calls the push service, the SW wakes within seconds, payloads arrive encrypted through the push subscription; use when the server knows something happened now. The decision tree: did the user just do something that must eventually reach the server? one-shot sync. Should the app feel fresh when opened? periodic sync (or plain stale-while-revalidate caching, which needs none of this). Did the server just get new data? push. They compose: an offline mail app uses sync for outgoing mail, push for incoming mail, periodic for a mailbox pre-warm - and all three funnel into the same IndexedDB and the same notification/UI layer. Coverage note: one-shot sync and periodic sync are Chromium-only; push is everywhere. So your Firefox and Safari story is: foreground retry on reconnect (the online event plus a drain on visibilitychange) - implement the drain as one function shared by the sync handler and the foreground path, and the missing API costs you only the while-closed window.
What breaks background sync in practice, and how do I debug a sync that never fires?
The failure list is short and each item has a signature. No active service worker: register() rejects immediately - check navigator.serviceWorker.controller and await ready before registering. Non-Chromium browser: 'sync' in reg is false - feature-detect, never assume. Tag already registered: silently deduplicated - two rapid sends in one tick become one event; queue, don't tag. The event fires but your handler throws early (a bug in drain, a missing IndexedDB record after an eviction) - the browser retries with backoff, and after enough failures gives up; you see nothing in the page because the work is in the SW. The classic silent killer: your drain succeeds but your code deletes the item before confirming the response - a crash between send and delete loses the message; order is send, verify, delete. Another: long-running drains (hundreds of items) exceed the SW lifetime budget - event.lastChance signals the deadline, so persist your position in the queue (a cursor record) and resume on the next delivery instead of throwing. Debugging tools: chrome://serviceworker-internals shows registrations; DevTools Application > Service Workers has a Sync tag input to fire your event by hand - the single most useful trick, because it lets you test the handler without waiting for the browser's schedule; and Application > Background Services records a timeline of sync dispatches with success/failure and lastChance flags. For the timing question ('is it registered?'), registration.sync.getTags() returns pending tags - log it after register and again in the sync handler; a tag that never appears in the handler means the SW failed to activate (check the worker's own install/activate errors), not that sync is broken.