Introduction to Web Background Sync
Web Background Sync is a powerful web API that allows web applications to defer actions until the user has stable connectivity. Instead of failing when a network request cannot complete due to poor or absent connectivity, your app can schedule a "sync" event that the browser will fire once connectivity returns. This transforms flaky network experiences into resilient, app-like interactions.
Originally proposed as part of the Service Worker specification family, Background Sync is currently supported in Chromium-based browsers (Chrome, Edge, Opera, Samsung Internet). For unsupported browsers, you should provide graceful fallbacks, which we will cover later in this guide.
Why Background Sync Matters
Mobile users frequently switch between Wi-Fi, cellular networks, and offline states. Traditional web apps handle this poorly: a form submission during a subway ride simply fails, and the user must remember to retry. Native mobile apps solved this long ago with background queues. Background Sync brings the same capability to the web.
Key Benefits
- Reliability: User actions are preserved and retried automatically.
- Better UX: Users can continue interacting with the app without waiting for network confirmation.
- Battery and data efficiency: The browser batches sync work and defers it until connectivity is optimal.
- Decoupled lifecycle: Sync can complete even if the user closes the tab, because the service worker handles it.
Prerequisites and Setup
Background Sync requires a registered service worker and a secure context (HTTPS or localhost). You also need to register a sync event from a page script and listen for it inside the service worker. Let's start with a minimal service worker registration.
// app.js — main page script
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const reg = await navigator.serviceWorker.register('/sw.js');
console.log('Service worker registered with scope:', reg.scope);
} catch (err) {
console.error('Service worker registration failed:', err);
}
});
}
Next, create the service worker file at the root of your site so its scope covers the entire application.
// sw.js — service worker
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
Storing Pending Work
Before requesting a sync, you must persist the work you want to perform later. IndexedDB is the recommended storage mechanism because it is asynchronous, supports structured data, and is accessible from both the page and the service worker. The example below uses a small wrapper around IndexedDB to keep a queue of pending requests.
// db.js — shared IndexedDB helper
const DB_NAME = 'sync-demo';
const STORE_NAME = 'pending-requests';
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function savePendingRequest(payload) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).add({
url: payload.url,
method: payload.method || 'POST',
body: payload.body,
createdAt: Date.now()
});
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function getAllPendingRequests() {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const req = tx.objectStore(STORE_NAME).getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function deletePendingRequest(id) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
Registering a Sync Event
When the user performs an action that requires network access, attempt the request immediately. If it fails due to a network error, store the payload and register a sync event. The browser will fire the sync event when connectivity is restored.
// app.js — submitting a form with offline support
import { savePendingRequest } from './db.js';
async function submitPost(message) {
try {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
if (!res.ok) throw new Error('Server error');
console.log('Posted immediately');
} catch (err) {
// Network failed — store and schedule sync
await savePendingRequest({
url: '/api/posts',
method: 'POST',
body: JSON.stringify({ message })
});
if ('SyncManager' in window) {
const reg = await navigator.serviceWorker.ready;
await reg.sync.register('sync-posts');
console.log('Sync registered. Will retry when online.');
} else {
// Fallback: retry on the next 'online' event
window.addEventListener('online', () => {
submitPost(message);
}, { once: true });
console.log('Background Sync unsupported. Will retry when back online.');
}
}
}
The string passed to sync.register() is a tag. Tags are unique identifiers; registering the same tag twice will not create duplicate sync events. If multiple pending actions share a tag, only one sync event fires, and your service worker should drain the entire queue.
Handling the Sync Event in the Service Worker
The actual work happens inside the service worker. Listen for the sync event, match the tag, and replay the stored requests. Use event.waitUntil() to keep the service worker alive until all work completes. If the promise rejects, the browser will retry the sync later with exponential backoff.
// sw.js — handling sync events
import { getAllPendingRequests, deletePendingRequest } from './db.js';
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-posts') {
event.waitUntil(replayPendingRequests());
}
});
async function replayPendingRequests() {
const pending = await getAllPendingRequests();
await Promise.all(pending.map(async (item) => {
try {
const res = await fetch(item.url, {
method: item.method,
headers: { 'Content-Type': 'application/json' },
body: item.body
});
if (!res.ok) throw new Error('Request failed: ' + res.status);
await deletePendingRequest(item.id);
await notifyClients({ status: 'success', id: item.id });
} catch (err) {
console.error('Replay failed for item', item.id, err);
// Throwing here causes the browser to retry the sync later
throw err;
}
}));
}
async function notifyClients(message) {
const clients = await self.clients.matchAll({ includeUncontrolled: true });
clients.forEach((client) => client.postMessage(message));
}
Using ES module imports in a service worker requires the type option during registration: navigator.serviceWorker.register('/sw.js', { type: 'module' }). If you need broader browser support, bundle your service worker with a tool like Workbox or esbuild instead.
Periodic Background Sync
The related Periodic Background Sync API lets your app wake up at intervals to refresh content, even when the user is not actively using the site. This is ideal for news apps, email, or dashboards. Periodic sync requires a installed PWA (added to home screen) and explicit user permission.
// app.js — registering periodic sync
async function registerPeriodicSync() {
if ('serviceWorker' in navigator && 'periodicSync' in ServiceWorkerRegistration.prototype) {
try {
const reg = await navigator.serviceWorker.ready;
const status = await navigator.permissions.query({ name: 'periodic-background-sync' });
if (status.state !== 'granted') {
console.warn('Periodic sync permission not granted');
return;
}
await reg.periodicSync.register('refresh-content', {
minInterval: 24 * 60 * 60 * 1000 // 24 hours
});
console.log('Periodic sync registered');
} catch (err) {
console.error('Periodic sync registration failed:', err);
}
}
}
// sw.js — handling periodic sync
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'refresh-content') {
event.waitUntil(refreshContent());
}
});
async function refreshContent() {
try {
const cache = await caches.open('content-cache');
const res = await fetch('/api/latest');
if (res.ok) {
await cache.put('/latest', res.clone());
await notifyClients({ status: 'content-updated' });
}
} catch (err) {
console.error('Periodic refresh failed:', err);
}
}
Best Practices
- Always persist work first. Never rely on in-memory state, because the page may close before the sync fires.
- Use meaningful sync tags. Group related work under one tag and drain the queue in the service worker rather than registering many tags.
- Make replay idempotent. The browser may fire the sync event multiple times. Ensure duplicate requests do not create duplicate server-side records by using client-generated IDs or deduplication keys.
- Handle failures gracefully. Throw from
event.waitUntil()only for transient failures (network, 5xx). For permanent failures (4xx), remove the item from the queue and notify the user. - Notify the UI. Use
postMessageto inform open tabs when sync completes so the interface can update without a reload. - Provide fallbacks. Listen for the
onlineevent and retry manually in browsers without SyncManager support. - Respect user privacy. Avoid syncing sensitive data in the background without clear disclosure. Periodic sync requires explicit permission for good reason.
- Test offline scenarios. Use Chrome DevTools' Network tab and Application > Service Workers > Offline toggle to simulate connectivity loss.
Debugging Tips
Chrome DevTools provides excellent tooling for Background Sync. Under Application > Service Workers, you can manually dispatch a sync event by clicking "sync" next to a registered tag, which is invaluable for testing without waiting for real network changes. The Application > IndexedDB panel lets you inspect your pending queue. Use chrome://serviceworker-internals/ for deeper inspection of service worker lifecycle events.
Conclusion
Web Background Sync bridges the gap between native and web applications by giving developers a reliable mechanism to defer and retry network operations. By combining a service worker, IndexedDB persistence, and the SyncManager API, you can build web experiences that survive poor connectivity gracefully and feel dependable to users. Pair it with Periodic Background Sync for content refresh, always provide fallbacks for unsupported browsers, and follow idempotency best practices to ensure your replay logic is robust. With these patterns in place, your web app will deliver a resilient, app-like experience regardless of network conditions.