Web Platform · Progressive Web
Background Sync and Deferred Work
How sync tags schedule retries after a failed request, why the payload must live in IndexedDB, and what the periodic variant does and does not guarantee about timing.
A page that loses connectivity mid-request has a problem the page itself cannot solve: if the user closes the tab, nothing remains to retry. Background Sync moves the retry into the service worker, where it can run without any document. The API surface is small — one method to register a tag, one event when the browser decides to fire it — and almost all of the engineering difficulty lies in what the developer must store and guarantee for the retry to be safe.
A tag is a retry intent, not a queue
registration.sync.register(tag) records that work labelled tag should be attempted. The browser fires a sync event with that tag when it believes connectivity has returned, and it may fire it more than once, with increasing delays between attempts. The event object exposes tag and lastChance, a boolean that is true on the final attempt before the browser abandons the retry.
The registration itself carries no payload. A tag is a string, and registering the same tag while an attempt is pending coalesces rather than adding a second item. Two registrations with the same tag do not mean two operations, so a design that registers 'upload' for every queued file needs another mechanism to enumerate them.
// Page context: write the intent to IndexedDB, then ask for a retry.
function openDb() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('outbox-db', 1);
request.onupgradeneeded = () => {
const store = request.result.createObjectStore('outbox', { keyPath: 'id' });
store.createIndex('by-state', 'state');
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function queueUpload(file) {
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction('outbox', 'readwrite');
tx.objectStore('outbox').put({
id: crypto.randomUUID(), target: '/api/upload', blob: file, state: 'pending'
});
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
db.close();
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('outbox-flush');
}
If registration succeeds, the browser keeps the intent across navigations and restarts. If the origin has no active worker, register() rejects; if the browser does not implement the API, registration.sync is undefined and the call throws before a promise exists. A fallback path that retries while the page is open is still required.
The payload belongs in IndexedDB
Because the worker can be terminated between events and the sync event can arrive long after the tab that queued the work is gone, the payload must be in durable storage. IndexedDB is the only browser store with the combination the case needs: structured values including Blob and ArrayBuffer, transactions, and availability from inside a worker. localStorage is unavailable in workers entirely. Keeping the queue in memory loses it on the same termination that makes the retry necessary.
A queue row needs four fields to be operable: a stable identifier, the payload, the target, and a state. The state machine is what makes retries safe — pending to sending to done, with a failure path back to pending or to a terminal failed. Claiming pending rows by moving them to sending inside one read-write transaction, before any network call, prevents two concurrent invocations of the same sync event, or a sync event racing a page-initiated attempt, from sending the same payload twice.
// Worker script: openDb() is the page helper above, repeated or imported.
function claimPending(db) {
return new Promise((resolve, reject) => {
let claimed = [];
const tx = db.transaction('outbox', 'readwrite');
const store = tx.objectStore('outbox');
store.index('by-state').getAll('pending').onsuccess = (event) => {
claimed = event.target.result.map((item) => ({ ...item, state: 'sending' }));
for (const item of claimed) store.put(item);
};
tx.oncomplete = () => resolve(claimed);
tx.onerror = () => reject(tx.error);
});
}
function write(db, value) {
return new Promise((resolve, reject) => {
const tx = db.transaction('outbox', 'readwrite');
tx.objectStore('outbox').put(value);
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
}
self.addEventListener('sync', (event) => {
if (event.tag !== 'outbox-flush') return;
event.waitUntil((async () => {
const db = await openDb();
for (const item of await claimPending(db)) {
try {
const response = await fetch(item.target, { method: 'POST', headers: { 'Idempotency-Key': item.id }, body: item.blob });
await write(db, { ...item, state: response.ok ? 'done' : 'pending' });
} catch {
await write(db, { ...item, state: 'pending' });
throw new Error('retry');
}
}
})());
});
Rejecting the waitUntil promise is what tells the browser the attempt failed and should be retried. A handler that swallows every error and resolves reports success, and the queue stalls with rows marked pending that never get another event. The distinction between a transport failure, which deserves a retry, and a 4xx response such as a validation failure, which will fail identically on every attempt, has to be encoded explicitly, with retryable statuses such as 408 and 429 treated as transport failures. A validation error retried forever is a queue that never drains.
A row left in sending when the worker is terminated mid-request is invisible to the next claim query, so the row needs a claim timestamp that lets a later pass reclaim entries older than a chosen threshold. Without that recovery step the queue stalls on exactly the entry whose outcome is unknown.
Duplicate delivery is the normal case
The browser controls when sync fires, and it cannot know whether a request that timed out at the client actually reached the server. A fetch that fails after the server committed the write may be retried, and the server then sees the same upload twice. The worker cannot prevent this; only the server can, using an idempotency key derived from the queue row’s identifier and a record of which keys have already been applied. A client-generated UUID sent as a header alongside the body is the standard shape.
The same reasoning applies to ordering. Sync events carry no sequence number and can be fired while a previous attempt is still in flight after a worker restart. Two rows uploaded concurrently arrive in whatever order the network delivers them, so an operation whose meaning depends on ordering — a create followed by an update — must either be sent as one request or carry a version the server can use to reject out-of-order application.
lastChance exists for the case where the browser is about to stop trying. A handler that sees it can mark rows as failed and, if notification permission has been granted, tell the user that work is pending. There is no event for “the user came back online and the queue drained”; the page that queued the work must observe the outcome by reading IndexedDB when it next loads, or receive a message posted from the worker to its clients.
Periodic sync is a different contract
registration.periodicSync.register(tag, { minInterval }) asks for the periodicsync event to fire repeatedly, at a cadence no faster than minInterval in milliseconds. It is not a timer. The browser decides when to fire based on site engagement, battery state, network type, and its own scheduling, and it may skip invocations entirely. The permission is separate from Notification permission and is queried with navigator.permissions.query({ name: 'periodic-background-sync' }); in Chromium the grant is tied to the site being installed and having sufficient engagement, and the permission can be revoked when engagement drops.
The interval is a floor, not a schedule: a minInterval of twelve hours means “not more often than”, and an implementation that fires once a week still satisfies it. Work that must happen at a known time belongs on a server. Periodic sync suits prefetching content — pulling the next day’s headlines, checking for updated documents — where lateness is acceptable and absence is tolerable.
Both APIs share a constraint that is easy to miss: they require an active service worker registration for the origin, and the events are dispatched to that worker’s global scope. A registration that has been unregistered, or a worker whose install failed, receives neither. The states a registration passes through before it can receive either event are described in Service Worker Lifecycle and the Registration Scope. An origin with two registrations on different scopes receives the events on the registration that scheduled them, which means a sync tag is scoped by registration and can coexist with an identically named tag elsewhere on the site; handlers must not assume one global queue.
When the retry never happens
Support is not universal, so a browser that lacks the API needs a page-level fallback. Feature detection is a single check for 'sync' in registration, and the fallback is a page-level retry loop driven by the online event plus a backoff timer, with the queue in IndexedDB either way so that both paths share the same recovery logic. Because the queue is durable, a page that reloads can resume a job the worker never received.
Even where the API exists, the browser may give up. Sync attempts are bounded by a browser-defined retry policy, and an origin that fails persistently stops being scheduled. A queue that has not drained by the next visit should be retried from the page on load, regardless of whether a sync event is expected; the API reduces the latency of recovery, and the durable queue is what makes recovery possible at all.
The interaction with response handling is where much of the complexity concentrates. Replaying an upload whose response was never stored means the retry must be able to reconstruct what the server would have returned, which in practice means the server must accept the same idempotency key twice. Request bodies and their consumption rules, which determine what can be stored for a replay, are covered in Request Cloning and Body Streams.
References
- Web Background Synchronization — W3C Web Incubator Community Group
- Periodic Background Synchronization — W3C Web Incubator Community Group
- SyncManager — MDN Web Docs