Background Sync API Implementation
The Background Sync API lets Progressive Web Applications and offline-first architectures defer network requests until a stable connection is available. By decoupling user interaction from network execution, you can guarantee data persistence and eventual consistency without blocking the main thread. This guide details production-ready implementation patterns, cross-browser constraints, and resilient queue management, and it sits inside the broader Offline Sync Strategies & Background Workflows guide, where it complements the caching and conflict-resolution patterns that surround it.
1. Environment Initialization & Sync Registration
1.1 Capability Detection & Worker Registration
Before invoking sync primitives, verify runtime support. The Background Sync API is currently supported in Chromium-based browsers (Chrome, Edge, Opera). Firefox and Safari do not implement SyncManager; these require the fallback strategies described in section 4. Implement strict feature detection for both navigator.serviceWorker and SyncManager to prevent unhandled promise rejections in unsupported environments.
Register the service worker with updateViaCache: 'none' to bypass HTTP cache validation during worker updates, ensuring deterministic sync triggers. This initialization phase underpins everything else in Offline Sync Strategies & Background Workflows, so keep the worker lifecycle consistent across application states.
async function initializeSyncEnvironment(): Promise<ServiceWorkerRegistration | null> {
if (!('serviceWorker' in navigator) || !('SyncManager' in window)) {
console.warn('Background Sync API not supported. Fallback required.');
return null;
}
const registration = await navigator.serviceWorker.register('/sw.js', {
updateViaCache: 'none',
scope: '/',
});
return registration;
}
1.2 Tag-Based Sync Registration
Sync operations are dispatched using string-based tags. Invoke registration.sync.register('unique-tag') to schedule a sync; the browser fires the sync event in the service worker once connectivity is confirmed. Always wrap registration in a try/catch block to handle NotAllowedError (triggered by missing permissions) and other errors gracefully.
Persist operation metadata to IndexedDB before dispatching the sync tag. This guarantees that if the browser terminates the worker before the sync event fires, the payload remains recoverable.
async function registerSyncOperation(tag: string, payload: unknown): Promise<void> {
try {
// Persist to IndexedDB first — the sync tag is just a wake-up signal.
await idb.put('sync_queue', {
id: crypto.randomUUID(),
tag,
data: payload,
createdAt: Date.now(),
});
const registration = await navigator.serviceWorker.ready;
await registration.sync.register(tag);
} catch (err) {
if ((err as Error).name === 'NotAllowedError') {
console.error('Sync registration requires a secure context and service worker.');
} else if ((err as Error).name === 'QuotaExceededError') {
console.error('Storage quota exceeded. Implement LRU eviction.');
}
throw err;
}
}
The two named tag styles you will reach for are a single fixed tag ('sync-payloads') that drains everything in the queue, and a per-record tag when you need isolated retry budgets. A one-off tag fires exactly once when connectivity returns; if you instead need a recurring server poll on an interval, that is a different primitive entirely, compared head-to-head in Periodic Background Sync vs One-Off Sync.
2. Core Event Handling & Queue Processing
2.1 Sync Event Listener Architecture
Bind the sync event listener at the top level of your service worker scope. Route event.tag values to isolated async handler functions to maintain separation of concerns and simplify debugging. Use event.waitUntil() to extend the service worker’s lifecycle until the promise resolves. Browsers impose execution budgets on background sync events (typically a few minutes in Chrome); exceeding this limit aborts the sync cycle and may trigger a retry.
Coordinate payload hydration with Service Worker Caching Strategies during offline-to-online transitions so cached assets and API endpoints are primed before network dispatch.
self.addEventListener('sync', (event: SyncEvent) => {
if (event.tag === 'sync-payloads') {
// event.waitUntil() prevents the SW from being killed until the queue drains.
event.waitUntil(processSyncQueue());
}
});
async function processSyncQueue(): Promise<void> {
const queue = await idb.getAll('sync_queue');
for (const item of queue) {
try {
const res = await fetch(item.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item.data),
signal: AbortSignal.timeout(30000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
// Atomic removal only on a successful 2xx response.
await idb.delete('sync_queue', item.id);
} catch (err) {
console.error(`Sync failed for item ${item.id}:`, err);
// Throwing here signals the browser to retry the sync event automatically.
throw err;
}
}
}
2.2 Payload Serialization & Queue Management
Implement a strict FIFO queue using IndexedDB for atomic operation staging. Avoid localStorage due to synchronous blocking and string-only limitations. Apply exponential backoff logic at the application layer if your backend returns 429 Too Many Requests, though the browser’s native retry mechanism handles transient 5xx errors automatically.
Structure payloads to be idempotent — include a stable UUID so that duplicate sync attempts (which the browser may trigger on flaky networks) can be safely deduplicated on the server. The end-to-end pattern for idempotent payloads and server-side deduplication is walked through in Implementing Reliable Background Sync for Form Submissions.
2.3 Single-Drain Coordination Across Tabs
A subtle hazard appears when multiple tabs each register the same tag, or when a foreground fallback fires while the service worker is already draining. Two concurrent drains can fetch the same record before either deletes it, producing duplicate writes the server must absorb. Idempotency keys make this survivable, but the cleaner fix is to serialize the drain so only one runs at a time. The Web Locks API for Cross-Tab Coordination gives you exactly that primitive — wrap the drain in a named lock and any second caller waits or bails.
async function drainGuarded(): Promise<void> {
// ifAvailable: bail immediately if another context already holds the lock.
await navigator.locks.request('sync-drain', { ifAvailable: true }, async (lock) => {
if (!lock) return; // Another tab or the SW is already draining.
await processSyncQueue();
});
}
3. State Conflicts & Storage Constraints
3.1 Divergent State Resolution
Offline-first architectures inevitably encounter state divergence. Attach server-side version vectors or logical clocks to queued payloads during creation. Before committing merged states to the local database, apply deterministic Conflict Resolution Algorithms to reconcile concurrent modifications.
Handle partial queue failures with IndexedDB transaction rollbacks. If a batch of ten operations fails on item seven, wrap the deletion logic in a single transaction to prevent orphaned or partially-synced records from persisting in the queue.
3.2 Quota Enforcement & Eviction
Browsers enforce strict storage quotas per origin. Poll navigator.storage.estimate() before queue insertion to calculate available headroom. Implement a least-recently-used eviction policy for payloads exceeding predefined TTL thresholds (for example, seven days).
When storage limits are critically reached, gracefully degrade to local-only persistence and notify the UI layer. Do not attempt to force sync registration when QuotaExceededError is imminent, as this adds pressure without relief.
async function hasHeadroom(estimatedBytes: number): Promise<boolean> {
if (!navigator.storage?.estimate) return true; // Assume room where unsupported.
const { usage = 0, quota = 0 } = await navigator.storage.estimate();
return quota - usage > estimatedBytes;
}
4. Telemetry, Fallbacks & Production Monitoring
4.1 DevTools Inspection & Metric Tracking
Use the Chrome DevTools Application > Background Sync panel to manually trigger tags, inspect queued payloads, and simulate offline/online states. Instrument sync lifecycle events with correlation IDs to enable distributed tracing across client, CDN, and backend layers.
Monitor retry counts, latency percentiles, and success rates via Real User Monitoring. Track event.lastChance specifically, as high exhaustion rates indicate systemic network instability or backend endpoint degradation.
4.2 Fallback Execution & Recovery
The Background Sync API does not guarantee infinite retries. Detect event.lastChance === true to handle permanent background sync exhaustion. When the browser determines a sync operation will never succeed natively, queue pending operations for foreground execution on visibilitychange or online events. This same foreground path is your only option on Firefox and Safari, which lack SyncManager entirely.
// Foreground fallback for browsers lacking native sync support (Firefox, Safari)
// or when background sync exhausts its retry budget.
if (!('SyncManager' in window)) {
window.addEventListener('online', async () => {
const pending = await idb.getAll('sync_queue');
for (const item of pending) {
await processSyncItem(item); // Reuse core processing logic.
}
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && navigator.onLine) {
triggerForegroundSync();
}
});
}
// Handle lastChance exhaustion inside the sync event.
self.addEventListener('sync', (event: SyncEvent) => {
if (event.tag === 'sync-payloads') {
event.waitUntil(
processSyncQueue().catch((err) => {
if (event.lastChance) {
console.warn('Background sync exhausted. Queueing for foreground fallback.');
// Mark items for foreground processing on the next tab open.
return idb.put('sync_queue_meta', { fallbackRequired: true, tag: event.tag });
}
throw err;
}),
);
}
});
Browser Compatibility
| Browser | SyncManager (one-off) |
Notes |
|---|---|---|
| Chrome / Edge (desktop) | Yes | Full support; DevTools Background Sync panel available. |
| Chrome (Android) | Yes | Honors OS power-saving; sync may defer until charging or Wi-Fi. |
| Opera | Yes | Chromium-based; matches Chrome behavior. |
| Firefox | No | No SyncManager; use the online/visibilitychange foreground fallback. |
| Safari (macOS 16/17) | No | No SyncManager; foreground fallback only. |
| iOS Safari 16/17 | No | No SyncManager, and Intelligent Tracking Prevention can evict the queue store after 7 days of inactivity — persist server-side recovery. |
Because roughly half of mobile traffic runs on engines without native background sync, treat the foreground fallback as the primary path and native sync as a Chromium optimization, not a requirement.
Performance & Scale Notes
- Batch, don’t flood. Draining a thousand queued records as a thousand serial
fetchcalls will blow past the sync execution budget. Coalesce small mutations into batched requests where your API supports it, and checkpoint deletions so a mid-drain abort resumes cleanly. - Keep payloads lean. Store only the minimum needed to reconstruct the request server-side; large blobs belong in their own store with references in the queue record.
- Respect power and metered networks. Chrome on Android may defer sync until charging or unmetered Wi-Fi, so never assume a registered tag fires immediately. User-facing state should reflect “queued,” not “sent.”
Frequently Asked Questions
Does the Background Sync API work in Safari and Firefox?
No. Neither implements SyncManager, so registration.sync.register is unavailable. Detect the absence and fall back to draining your IndexedDB queue on online and visibilitychange events from the foreground. See the fallback section above and Implementing Reliable Background Sync for Form Submissions for a complete example.
Why persist the payload to IndexedDB before registering the sync tag?
The sync tag is only a wake-up signal — it carries no data and the browser may terminate the worker before the event fires. Persisting the payload first means the record survives worker shutdown, reload, and reboot, and the sync handler reconstructs the request from durable storage rather than volatile memory.
How do I avoid duplicate submissions on a flaky network?
Make every queued record idempotent with a stable UUID sent as an idempotency key so the server can deduplicate replays. Then serialize the drain with the Web Locks API for Cross-Tab Coordination so two tabs cannot fetch the same record concurrently.
Is a one-off sync the same as periodic background sync?
No. A one-off sync fires once when connectivity returns and is meant to flush a queue. Periodic background sync wakes the worker on a browser-controlled interval to refresh data, requires an installed PWA, and has far narrower support. The trade-offs are detailed in Periodic Background Sync vs One-Off Sync.
Related
- Implementing Reliable Background Sync for Form Submissions — a complete, runnable form-queue walkthrough with idempotency keys.
- Periodic Background Sync vs One-Off Sync — when to schedule recurring syncs versus flushing a one-off queue.
- Web Locks API for Cross-Tab Coordination — serialize the drain so concurrent tabs never double-submit.
- Conflict Resolution Algorithms — reconcile divergent state once queued mutations reach the server.
- Offline Sync Strategies & Background Workflows — the parent guide covering caching, sync, and conflict handling end to end.