Introduction to Background Sync
Modern web applications face a fundamental challenge: what happens when a user performs a critical action — like submitting a form, placing an order, or sending a message — but their network connection drops mid-operation? Without a robust offline strategy, that data is often lost, leading to frustrated users and abandoned transactions. The Background Sync API, part of the Service Worker specification, solves this problem elegantly by deferring network operations until connectivity is restored, even if the user has closed the browser tab.
What is Background Sync?
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Background Sync is a web API that allows you to register a named synchronization task with the browser's service worker. When the user's device regains internet connectivity, the browser fires a sync event inside the service worker, giving you the opportunity to complete pending operations — such as sending queued API requests, uploading cached form data, or reconciling local database changes with a remote server.
The key distinction is that background sync works even after the page has been closed. Unlike fetch calls made directly from a page, which fail immediately when there's no network, sync events persist across page sessions. The browser keeps track of registered sync tags and triggers them at the optimal moment: when connectivity is available and the device is not under resource constraints.
There are two variants of the API:
- Background Sync (
SyncManager) — fires a one-off sync event when the browser detects network availability. Ideal for completing deferred writes or sending queued payloads. - Periodic Background Sync (
PeriodicSyncManager) — fires sync events on a recurring interval (e.g., every few hours) regardless of page state. Useful for fetching fresh content, updating caches, or performing regular health checks. This variant requires a browser engagement heuristic or installed PWA status.
Why Background Sync Matters
Background Sync directly addresses several real-world pain points in web application development:
- Offline resilience — Users in areas with intermittent connectivity (subways, rural zones, conference Wi-Fi) can continue working. Their actions are safely stored and processed when the network returns.
- Improved user experience — Instead of presenting error modals or forcing users to manually retry, the application gracefully handles the retry behind the scenes. The user perceives a seamless, native-like experience.
- Reduced data loss — Critical transactions like payments, form submissions, or collaborative edits are not silently dropped. The sync mechanism guarantees eventual consistency.
- Battery and resource efficiency — Rather than polling continuously from an open page or implementing custom retry loops, the browser intelligently batches sync operations when radio conditions are favorable, conserving battery life.
- Progressive Web App enhancement — Background Sync is a hallmark of capable PWAs, helping web apps close the gap with native mobile applications that have long enjoyed background execution privileges.
How to Implement Background Sync
Prerequisites
Before writing any code, ensure your environment meets these requirements:
- HTTPS — Service Workers and the Sync API require a secure context. Localhost is permitted during development.
- Service Worker registration — The sync event fires inside an active service worker. You must have a registered service worker controlling at least one client page.
- Browser support — Chromium-based browsers (Chrome, Edge, Opera) support Background Sync. Firefox and Safari currently do not. Always use feature detection.
Step 1: Register a Service Worker
Create a file named sw.js in your application root and register it from your main JavaScript file. The registration must succeed before you can request syncs.
// main.js — Register the service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('Service Worker registered with scope:', registration.scope);
})
.catch(error => {
console.error('Service Worker registration failed:', error);
});
}
Step 2: Add the Sync Event Listener in the Service Worker
Inside sw.js, listen for the sync event. The event object contains a tag property — the string name you chose when registering the sync. Use this tag to branch logic for different sync operations.
// sw.js — Listen for background sync events
self.addEventListener('sync', event => {
console.log('Sync event fired with tag:', event.tag);
if (event.tag === 'submit-order') {
// Hold onto the promise chain so the browser knows when sync is complete
event.waitUntil(submitPendingOrders());
}
if (event.tag === 'sync-messages') {
event.waitUntil(sendQueuedMessages());
}
});
async function submitPendingOrders() {
// Retrieve stored orders from IndexedDB or Cache
const pendingOrders = await getPendingOrdersFromStorage();
for (const order of pendingOrders) {
try {
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order)
});
if (response.ok) {
await removePendingOrderFromStorage(order.id);
console.log('Order submitted successfully:', order.id);
} else {
// Server rejected the payload — store for manual review
await markOrderAsFailed(order.id, response.status);
}
} catch (networkError) {
// Network still unstable — keep in queue, browser will retry later
console.warn('Sync attempt failed, will retry:', networkError);
throw networkError; // Throwing causes the browser to retry the sync
}
}
}
async function sendQueuedMessages() {
const messages = await getQueuedMessagesFromStorage();
const sendPromises = messages.map(msg =>
fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(msg)
}).then(response => {
if (response.ok) return removeQueuedMessage(msg.id);
throw new Error(`Server responded with ${response.status}`);
})
);
await Promise.all(sendPromises);
}
// Helper stubs — replace with actual IndexedDB or Cache API logic
async function getPendingOrdersFromStorage() {
// Retrieve from IndexedDB
return [];
}
async function removePendingOrderFromStorage(id) {
// Delete from IndexedDB
}
async function markOrderAsFailed(id, status) {
// Update record in IndexedDB
}
async function getQueuedMessagesFromStorage() {
return [];
}
async function removeQueuedMessage(id) {
// Delete from IndexedDB
}
The critical pattern here is event.waitUntil(promise). This tells the browser to keep the service worker alive until the promise resolves or rejects. If the promise rejects (e.g., the network request fails again), the browser may schedule a retry. If it resolves, the sync is considered complete.
Step 3: Request a Sync from the Client Page
From your application code, obtain the service worker registration and call sync.register() with a unique tag name. This queues the sync operation.
// checkout.js — Request a background sync after user places an order
async function placeOrder(orderData) {
// First, save the order locally so the service worker can access it later
await saveOrderToIndexedDB(orderData);
// Check if Background Sync is available
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const registration = await navigator.serviceWorker.ready;
try {
await registration.sync.register('submit-order');
console.log('Background sync registered for submit-order');
} catch (error) {
console.error('Sync registration failed:', error);
// Fallback: attempt immediate network submission
await attemptDirectSubmission(orderData);
}
} else {
// Fallback for browsers without SyncManager support
await attemptDirectSubmission(orderData);
}
}
async function saveOrderToIndexedDB(orderData) {
// Implementation using IndexedDB — store order with a unique ID
return new Promise((resolve, reject) => {
const request = indexedDB.open('OrderQueue', 1);
request.onupgradeneeded = event => {
const db = event.target.result;
if (!db.objectStoreNames.contains('orders')) {
db.createObjectStore('orders', { keyPath: 'id' });
}
};
request.onsuccess = event => {
const db = event.target.result;
const transaction = db.transaction('orders', 'readwrite');
const store = transaction.objectStore('orders');
store.put({ id: Date.now().toString(), ...orderData, status: 'pending' });
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
};
request.onerror = () => reject(request.error);
});
}
async function attemptDirectSubmission(orderData) {
try {
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(orderData)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
} catch (error) {
console.error('Direct submission failed:', error);
// Could store for manual retry
}
}
Step 4: Testing Your Implementation
Testing background sync requires simulating offline conditions. Use Chrome DevTools:
- Open DevTools and navigate to the Application tab.
- Select Service Workers in the left panel.
- Check the Offline checkbox to simulate a disconnected state.
- Trigger the action that calls
sync.register(). - Uncheck Offline. The sync event should fire automatically.
- In the Application tab, under Background Sync, you can see registered sync tags and their status.
Complete Example: Offline Form Submission
Here is a consolidated, production-ready example that ties all the pieces together. It handles an offline contact form submission using Background Sync and IndexedDB for local persistence.
// ==========================================
// FILE: public/index.html
// ==========================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Background Sync Demo</title>
</head>
<body>
<h1>Contact Us</h1>
<form id="contact-form">
<input type="text" name="name" placeholder="Your name" required>
<input type="email" name="email" placeholder="Your email" required>
<textarea name="message" placeholder="Your message" required></textarea>
<button type="submit">Send Message</button>
</form>
<p id="status"></p>
<script src="main.js"></script>
</body>
</html>
// ==========================================
// FILE: public/main.js
// ==========================================
// Register service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('SW registered:', reg.scope))
.catch(err => console.error('SW registration failed:', err));
}
// Open or create IndexedDB database
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('ContactFormQueue', 1);
request.onupgradeneeded = event => {
const db = event.target.result;
if (!db.objectStoreNames.contains('messages')) {
db.createObjectStore('messages', {
keyPath: 'id',
autoIncrement: true
});
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function storeMessage(data) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction('messages', 'readwrite');
const store = tx.objectStore('messages');
store.add({ ...data, timestamp: Date.now() });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
document.getElementById('contact-form').addEventListener('submit', async event => {
event.preventDefault();
const formData = {
name: event.target.name.value,
email: event.target.email.value,
message: event.target.message.value
};
// Store locally
await storeMessage(formData);
// Attempt background sync registration
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const registration = await navigator.serviceWorker.ready;
try {
await registration.sync.register('send-contact-message');
document.getElementById('status').textContent =
'Message saved. It will be sent when you are back online.';
event.target.reset();
} catch (err) {
document.getElementById('status').textContent =
'Sync not available. Message saved locally.';
console.error(err);
}
} else {
document.getElementById('status').textContent =
'Background Sync not supported. Message saved locally.';
}
});
// ==========================================
// FILE: public/sw.js
// ==========================================
const MESSAGE_API_URL = '/api/contact';
self.addEventListener('sync', event => {
if (event.tag === 'send-contact-message') {
event.waitUntil(processPendingMessages());
}
});
async function processPendingMessages() {
const db = await openSWDB();
const pendingMessages = await getAllPendingMessages(db);
for (const msg of pendingMessages) {
try {
const response = await fetch(MESSAGE_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: msg.name,
email: msg.email,
message: msg.message
})
});
if (response.ok) {
await deleteMessage(db, msg.id);
console.log('Message sent successfully:', msg.id);
} else {
console.warn('Server returned error for message:', msg.id, response.status);
// Keep in queue for manual review or retry
}
} catch (fetchError) {
console.error('Failed to send message, will retry:', fetchError);
throw fetchError; // Re-throw to signal retry
}
}
}
function openSWDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('ContactFormQueue', 1);
request.onupgradeneeded = event => {
const db = event.target.result;
if (!db.objectStoreNames.contains('messages')) {
db.createObjectStore('messages', { keyPath: 'id', autoIncrement: true });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function getAllPendingMessages(db) {
return new Promise((resolve, reject) => {
const tx = db.transaction('messages', 'readonly');
const store = tx.objectStore('messages');
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function deleteMessage(db, id) {
return new Promise((resolve, reject) => {
const tx = db.transaction('messages', 'readwrite');
const store = tx.objectStore('messages');
store.delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
Periodic Background Sync
While one-off sync is ideal for deferred writes, Periodic Background Sync enables regular, scheduled operations like fetching the latest news articles, updating weather data, or refreshing cached assets. This API is more restricted: it requires the site to be installed as a PWA (added to the home screen on mobile or installed via the browser menu on desktop) and the browser applies an engagement scoring heuristic to grant permission.
Registering a Periodic Sync
From the client page, use the periodicSync property on the service worker registration. You must also specify a minInterval in milliseconds, which is the minimum time between syncs. The browser may coalesce or delay syncs to align with system-wide background task scheduling.
// main.js — Register periodic background sync (requires PWA install)
async function registerPeriodicSync() {
if ('serviceWorker' in navigator && 'PeriodicSyncManager' in window) {
const registration = await navigator.serviceWorker.ready;
try {
const status = await navigator.permissions.query({
name: 'periodic-background-sync'
});
if (status.state === 'granted') {
await registration.periodicSync.register('fetch-news', {
minInterval: 60 * 60 * 1000 // 1 hour minimum
});
console.log('Periodic sync registered for fetch-news');
} else {
console.log('Periodic sync permission not granted');
}
} catch (error) {
console.error('Periodic sync registration failed:', error);
}
}
}
// Call this after user installs the PWA
window.addEventListener('appinstalled', () => {
registerPeriodicSync();
});
Handling Periodic Sync in the Service Worker
The service worker listens for periodicsync events similarly to regular sync events.
// sw.js — Handle periodic sync
self.addEventListener('periodicsync', event => {
if (event.tag === 'fetch-news') {
event.waitUntil(fetchLatestNewsAndNotify());
}
});
async function fetchLatestNewsAndNotify() {
try {
const response = await fetch('/api/latest-news');
const articles = await response.json();
// Cache the articles for offline reading
const cache = await caches.open('news-cache-v1');
await cache.put('/api/latest-news', new Response(JSON.stringify(articles)));
// Show a notification to the user
if (Notification.permission === 'granted') {
await self.registration.showNotification('Fresh News Available', {
body: `${articles.length} new articles ready to read`,
icon: '/icons/news-icon.png',
tag: 'news-update',
data: { url: '/news' }
});
}
console.log('Periodic sync completed: fetched', articles.length, 'articles');
} catch (error) {
console.error('Periodic sync failed:', error);
throw error;
}
}
Best Practices
- Use unique, descriptive sync tags — Tags like
'submit-order','sync-notes', or'upload-photos'make debugging easier and allow multiple independent sync operations to coexist. - Persist data before registering sync — Always write the payload to IndexedDB or the Cache API before calling
sync.register(). If the browser terminates the page immediately after registration, the service worker must find the data in storage. - Implement idempotent operations — Network requests triggered by sync should be idempotent where possible. Use unique request identifiers or ETags to prevent duplicate submissions if sync fires multiple times.
- Handle partial failures gracefully — If you're processing a batch of queued items, wrap each item's network call in its own try-catch. A single failure should not prevent other items from being attempted. Remove successfully processed items and leave failed ones for the next sync cycle.
- Set reasonable timeouts — Sync operations should complete within a reasonable window. Long-running syncs may be terminated by the browser. Break large workloads into smaller chunks processed across multiple sync events.
- Use
event.waitUntil()correctly — Always pass a promise towaitUntil(). If you forget this, the browser may terminate the service worker before your async work finishes, leaving data in an inconsistent state. - Provide user-visible feedback — When sync completes successfully, consider showing a notification (if the user has granted notification permission) or updating cached UI state so that when the user next opens the app, they see the resolved state.
- Test across network conditions — Use DevTools to simulate offline, slow 3G, and intermittent connectivity. Verify that sync fires reliably and that duplicate submissions don't occur.
- Guard with feature detection — Always check for
SyncManagerorPeriodicSyncManageravailability and provide fallback mechanisms (direct fetch attempts, manual retry buttons) for unsupported browsers. - Monitor sync frequency — Excessive sync registration can be throttled by the browser. Don't register a sync on every keystroke; batch operations and register syncs at meaningful checkpoints (form submission, order confirmation, note save).
Browser Support and Feature Detection
As of 2025, Background Sync is supported in Chromium-based browsers (Google Chrome, Microsoft Edge, Samsung Internet, Opera) on both desktop and Android. Firefox and Safari (including iOS Safari) have not implemented the API. Periodic Background Sync has an even narrower support footprint and requires PWA installation on supported platforms.
Use the following pattern to safely integrate sync capabilities while maintaining compatibility:
// Feature detection helper
const syncSupport = {
backgroundSync: 'serviceWorker' in navigator && 'SyncManager' in window,
periodicSync: 'serviceWorker' in navigator && 'PeriodicSyncManager' in window
};
async function safeSyncRegistration(tag) {
if (!syncSupport.backgroundSync) {
console.log('Background Sync not available — using direct network call');
return 'direct';
}
const registration = await navigator.serviceWorker.ready;
try {
await registration.sync.register(tag);
return 'sync-registered';
} catch (error) {
console.warn('Sync registration rejected:', error);
return 'rejected';
}
}
// Usage in your application logic
async function handleUserAction(data) {
await persistToStorage(data);
const result = await safeSyncRegistration('process-action');
if (result === 'direct') {
// Immediately attempt the network call
await attemptNetworkCall(data);
} else if (result === 'sync-registered') {
// Sync will handle it later — inform the user
showToast('Action saved. It will complete when you are back online.');
} else {
// Sync was rejected (e.g., due to frequency limits)
// Provide a manual retry option
showToast('Could not schedule background sync. Please try again later.');
}
}
Debugging and Troubleshooting
When background sync does not fire as expected, check the following:
- Service Worker is active — The sync event only fires in an active (not installing or waiting) service worker. Use
self.skipWaiting()andclients.claim()if you need immediate control. - Sync tag is registered — In Chrome DevTools, navigate to Application → Background Sync to see pending sync tags. If your tag is not listed, registration may have failed silently.
- Browser throttling — Browsers limit sync frequency. Rapidly registering the same tag multiple times may be coalesced into a single event. There is also a global per-origin limit on pending sync registrations.
- Network conditions — Sync fires when the browser detects a stable network connection. On some platforms, this detection may take a few seconds after connectivity is restored.
- Promise rejection handling — If the promise passed to
waitUntil()rejects, the browser may retry the sync later. However, repeated failures can lead to the browser backing off exponentially. Ensure your fetch logic has appropriate error handling.
Conclusion
Background Sync transforms the web from a strictly online medium into a resilient, offline-capable platform. By deferring network writes to moments of connectivity — and doing so even after the user closes the browser — you eliminate one of the most common sources of user frustration: lost work due to flaky connections. The implementation pattern is straightforward: persist data locally, register a named sync, handle the sync event in your service worker with waitUntil(), and iterate through queued items until they are successfully sent. Combined with Periodic Background Sync for content freshness and IndexedDB for robust client-side storage, you can build web applications that rival native apps in reliability and user experience. As browser support continues to expand, integrating Background Sync into your offline strategy is an investment that pays dividends in user trust and engagement.