Background sync is an offline-preparedness workflow, not just a "try again later
Practical service worker patterns for background sync that prepare your PWA for offline scenarios before they happen.
Why background sync needs a workflow, not just an API call
Background sync has a reputation as "magic that fixes failed requests when network returns." In practice, it requires a deliberate workflow: queue the action, store a copy, and surface it to the client when sync runs. Without this three-part pattern, sync fails silently or leaves the app in an inconsistent state.
The sync event itself only guarantees "the browser may run your service worker soon." It doesn't guarantee your service worker gets to all pending items, or that the client page sees the result. A sync workflow is the difference between actions that actually happen and actions that look like they did.
Build a sync-first workflow, not a sync-as-fix
Treat background sync as part of every action, not an afterthought:
- Before the request: Store the action in IndexedDB with a pending status.
- Fire the request: Try the network fetch immediately.
- On fetch success: Mark the action as completed, clean up the queue.
- On fetch failure: Register a background sync event for the action type.
- In sync handler: Process pending actions from IndexedDB, mark completed.
- Notify clients: Use
Client.postMessage()or the Broadcast Channel API so open pages update their UI.
The pattern works best when the action state is your source of truth. Don't rely on fetch() alone—store what happened and what's pending.
Use a sync queue with retry logic
Background sync doesn't retry automatically for you. Build a small queue system:
// IndexedDB schema for pending actions
const SYNC_QUEUE_STORE = 'syncQueue';
async function enqueueAction(action) {
return db.add(SYNC_QUEUE_STORE, {
id: crypto.randomUUID(),
type: action.type,
payload: action.payload,
status: 'pending',
createdAt: Date.now(),
attemptCount: 0
});
}
async function markActionCompleted(actionId) {
await db.delete(SYNC_QUEUE_STORE, actionId);
}
async function getNextPendingActions(limit = 10) {
return db.getAll(SYNC_QUEUE_STORE, IDBKeyRange.lowerBound(0), limit);
}In the service worker sync event, process actions in batches and handle partial failures:
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-queue') {
event.waitUntil(processSyncQueue());
}
});
async function processSyncQueue() {
const actions = await getNextPendingActions();
for (const action of actions) {
try {
await syncAction(action);
await markActionCompleted(action.id);
} catch (error) {
action.attemptCount += 1;
if (action.attemptCount < 3) {
await db.put(SYNC_QUEUE_STORE, action); // Retry later
} else {
// Log permanently failed action or surface to user
}
}
}
}Surface sync status to open windows
Clients don't automatically know that sync ran. Communicate back:
// In service worker, after sync completes
self.clients.matchAll({ type: 'window' }).then(clients => {
clients.forEach(client => {
client.postMessage({ type: 'sync-completed', syncedAt: Date.now() });
});
});
// In client page
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data.type === 'sync-completed') {
refreshPendingActionsUI();
}
});This pattern lets the UI show accurate pending counts without polling the server.
Checklist: background sync readiness
Before you ship background sync:
- [ ] Every action stores its intent in IndexedDB before or during the fetch.
- [ ] Service worker sync event reads from a queue, not a single fetch.
- [ ] Partial failures are retried with an attempt count limit.
- [ ] Sync completion is posted back to open client pages.
- [ ] Couch/fallback UI shows pending action when sync hasn't run yet.
- [ ] Storage quota is cleared for completed actions to avoid quota limits.
- [ ] On Safari or browsers without sync, the graceful downgrade shows immediate network failure to the user.
What this means for installed users
Installation brings higher expectations for "it works even if I lose connection." Background sync is one piece, but it must be paired with immediate offline feedback: show what's queued, surface sync status, and let the user retry manually if sync fails permanently.
Treat sync as part of your data model, not as network error fixup. When the queue is your truth, the app stays consistent whether the user is online, offline, or in-between.