Background Sync
Use the Background Sync API to retry requests when connection returns — even after the user closes the tab.
Background Sync
Status: Available Pattern
Background Sync lets your service worker process the mutation queue even after the user closes the app.
What is Background Sync?
A browser API that:
- Registers a "sync" event tag in the SW from your client code
- Fires the SW's
syncevent when the browser detects a stable connection - Works even if the user closed the tab
- Sync events are one-time — must re-register after each sync
Note: Swoff covers the client-side registration and SW handler. Your backend receives the synced requests.
When to Use This
| Scenario | Use Background Sync? |
|---|---|
| User creates data and closes the tab | ✅ Yes — sync happens in background |
| User switches to another app | ✅ Yes — sync when connection stabilizes |
| User has unstable network | ✅ Yes — retries automatically |
| You only need sync while app is open | ❌ No — use Mutation Queuing's online event alone |
| Firefox / Safari users | ❌ Not supported — falls back to online event |
Browser Support
| Browser | Support |
|---|---|
| Chrome | ✅ Yes |
| Edge | ✅ Yes |
| Firefox | ❌ No (under consideration) |
| Safari | ❌ No (not on roadmap) |
Fallback: For unsupported browsers, the mutation queue auto-processes on the online event (already set up in Mutation Queuing). The fallback only works while the tab is open.
Prerequisites
- Mutation Queuing — the queue must be set up first
- SW Template — you'll add a
syncevent handler - Client Registration — SW must be registered
Implementation
Client: Register Sync After Queuing
Create swoff/background-sync.js:
import { queueMutation, processMutationQueue, getPendingCount } from "./mutation-queue.js";
const SYNC_TAG = "sync-mutations";
async function registerSync() {
if (!("serviceWorker" in navigator) || !("SyncManager" in window)) {
// Fallback: process when tab comes online
window.addEventListener("online", processMutationQueue, { once: true });
return;
}
try {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register(SYNC_TAG);
} catch {
// Registration failed — fallback to online event
window.addEventListener("online", processMutationQueue, { once: true });
}
}
/**
* Queue a mutation and try background sync.
* Falls back to online event listener in unsupported browsers.
* @param {object} mutation - { method, url, body, headers }
*/
export async function syncWhenPossible(mutation) {
await queueMutation(mutation);
await registerSync();
}Service Worker: Handle Sync Event
The SW needs its own queue processor since it has no DOM access. Add this to swoff/sw-template.js:
// Background sync — process mutation queue
self.addEventListener("sync", (event) => {
if (event.tag === "sync-mutations") {
event.waitUntil(processMutationQueueInSW());
}
});
const SW_DB_NAME = "swoff-queue";
const SW_STORE_NAME = "mutations";
const SW_MAX_RETRIES = 5;
function openSWQueueDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(SW_DB_NAME, 1);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(SW_STORE_NAME)) {
const store = db.createObjectStore(SW_STORE_NAME, { keyPath: "id" });
store.createIndex("by-timestamp", "timestamp");
}
};
request.onsuccess = (e) => resolve(e.target.result);
request.onerror = (e) => reject(e.target.error);
});
}
async function processMutationQueueInSW() {
let succeeded = 0;
let failed = 0;
const tagsToInvalidate = new Set();
try {
const db = await openSWQueueDB();
const tx = db.transaction(SW_STORE_NAME, "readonly");
const store = tx.objectStore(SW_STORE_NAME);
const index = store.index("by-timestamp");
const queue = await new Promise((resolve, reject) => {
const request = index.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
for (const item of queue) {
if (item.retryCount >= SW_MAX_RETRIES) {
await removeFromSWQueue(item.id);
failed++;
continue;
}
try {
const response = await fetch(item.url, {
method: item.method,
headers: {
"Content-Type": "application/json",
...item.headers,
},
body: JSON.stringify(item.body),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const serverData = await response.json();
// Reconcile: update local record with server response
if (item.tempId && item.storeName) {
await reconcileRecordInSW(item.storeName, item.tempId, serverData);
}
// Collect tags for invalidation
if (item.tags) {
item.tags.forEach((tag) => tagsToInvalidate.add(tag));
}
await removeFromSWQueue(item.id);
succeeded++;
} catch {
item.retryCount++;
await updateInSWQueue(item);
failed++;
}
}
} catch (err) {
console.error("Background sync failed:", err);
}
// Invalidate cached responses
for (const tag of tagsToInvalidate) {
await invalidateByTag(tag);
}
// Notify all clients so they can dispatch window events
const clients = await self.clients.matchAll();
for (const client of clients) {
client.postMessage({
type: "BACKGROUND_SYNC_COMPLETE",
detail: { succeeded, failed, tags: [...tagsToInvalidate] },
});
}
}
// SW-side reconcile — same logic as client-side reconcileRecord,
// but uses the shared app IndexedDB directly (no DOM needed)
async function reconcileRecordInSW(storeName, tempId, serverData) {
const db = await openAppDB(storeName);
const tx = db.transaction(storeName, "readwrite");
const store = tx.objectStore(storeName);
const existing = await new Promise((resolve, reject) => {
const request = store.get(tempId);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
if (!existing) return;
const reconciled = {
...existing,
...serverData,
id: serverData.id,
$synced: true,
$syncedAt: Date.now(),
};
store.put(reconciled);
if (String(tempId) !== String(serverData.id)) {
store.delete(tempId);
}
await new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// Open the app's main IndexedDB to reconcile records
// Change DB_NAME to match your app's database name
async function openAppDB(storeName) {
const APP_DB_NAME = "my-app"; // ← Change to your app's DB name
return new Promise((resolve, reject) => {
const request = indexedDB.open(APP_DB_NAME);
request.onsuccess = (e) => resolve(e.target.result);
request.onerror = (e) => reject(e.target.error);
});
}
async function removeFromSWQueue(id) {
const db = await openSWQueueDB();
const tx = db.transaction(SW_STORE_NAME, "readwrite");
tx.objectStore(SW_STORE_NAME).delete(id);
await new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function updateInSWQueue(item) {
const db = await openSWQueueDB();
const tx = db.transaction(SW_STORE_NAME, "readwrite");
tx.objectStore(SW_STORE_NAME).put(item);
await new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}Important: The SW accesses the same IndexedDB database (swoff-queue) as the client code. Both must use the same DB_NAME and STORE_NAME. The onupgradeneeded handler is a no-op if the database already exists, so it's safe to include in both places.
SW Template Changes Summary
Add these blocks to your existing swoff/sw-template.js:
| Block | Location | Purpose |
|---|---|---|
self.addEventListener("sync", ...) | Anywhere in file | Listen for background sync events |
openSWQueueDB(), processMutationQueueInSW(), helpers | After event listeners | Queue processing, reconcile, invalidate, client notification |
reconcileRecordInSW() | After helpers | Server response → local record update |
openAppDB() | After helpers | Open app's IndexedDB for reconciliation |
The SW template's fetch handler already lets mutations pass through (see isReadRequest in SW Template). No changes needed there. The tag-indexed caching and INVALIDATE_TAG handler are added separately — see SW Template.
Client: Handle SW Sync Completion
When the SW processes the queue in background (tab may be closed), the client needs to pick up the results when the user returns. Add this listener in your app init:
// Listen for background sync results from the SW
navigator.serviceWorker.addEventListener("message", (event) => {
if (event.data.type === "BACKGROUND_SYNC_COMPLETE") {
const { succeeded, failed, tags } = event.data.detail;
window.dispatchEvent(
new CustomEvent("mutation-sync-complete", {
detail: { succeeded, failed },
}),
);
if (tags && tags.length > 0) {
window.dispatchEvent(
new CustomEvent("cache-invalidated", { detail: { tags } }),
);
}
window.dispatchEvent(new CustomEvent("mutation-queue-changed"));
}
});This ensures that even if the background sync happens while the tab is closed, when the user returns the UI updates correctly — stale data is refetched, indicators update.
Re-registration Pattern
Sync events are one-time — after the SW processes the queue, it won't fire again until a new sync.register() call. If the queue still has items after processing (e.g., some failed), re-register:
// Append to swoff/background-sync.js
/**
* Re-register for background sync if there are still pending mutations.
*/
export async function retrySync() {
if (!("serviceWorker" in navigator) || !("SyncManager" in window)) return;
const count = await getPendingCount();
if (count > 0) {
await registerSync();
}
}
// Auto-retry after each sync completes
window.addEventListener("mutation-sync-complete", retrySync);Fallback Strategy
The syncWhenPossible() implementation above already handles unsupported browsers — it falls back to the online event listener. For browsers that support Background Sync but where registration fails, it also falls back.
The mutation queue's primary online event listener (set up in swoff/mutation-queue.js) serves as a secondary fallback for any queued items not caught by background sync.
No additional code needed — the client implementation above covers all cases:
| Browser | Behavior |
|---|---|
| Chrome / Edge | Background sync — works even after tab close |
| Firefox / Safari | online event listener — works while tab is open |
| Any (sync registration fails) | online event listener — works while tab is open |
Limitations
- No payload — can't pass data to sync event (must store in IDB)
- No guarantee — browser decides when to fire
- One-time — must re-register after each sync
- Browser support — Chrome/Edge only (Firefox/Safari fall back to
onlineevent) - SW lifespan — the SW can be terminated at any time; long-running sync may be interrupted
Integration Checklist
- Add
syncevent listener +processMutationQueueInSW()+reconcileRecordInSW()+openAppDB()toswoff/sw-template.js - Create
swoff/background-sync.jswithsyncWhenPossible()+ support detection - Add
BACKGROUND_SYNC_COMPLETEmessage listener in your app init (e.g.,sw-injector.js) - Replace direct
queueMutation()calls withsyncWhenPossible()where background sync is desired - (Optional) Add
retrySync()+mutation-sync-completelistener for persistent retry - Update
openAppDB(storeName)in SW to match your app's IndexedDB database name - Test in Chrome/Edge (background sync works after tab close)
- Test in Firefox/Safari (falls back to
onlineevent while tab is open) - Your backend receives the synced requests
Status
- Background Sync support detection
- Client-side registration wrapper (
syncWhenPossible()) - Fallback for unsupported browsers (Firefox, Safari)
- SW
syncevent handler (processMutationQueueInSW()) - SW-side reconcile + cache invalidation
- Client notification on background sync completion
- Re-registration pattern for persistent retry
- Integration with mutation queue (shared
swoff-queuedatabase) - Framework adapters — reuse
useMutationQueuefrom Mutation Queuing
Next Steps
- Mutation Queuing — the queue that powers the sync
- Conflict Resolution — handle sync conflicts
Swoff