· deepdives · 8 min read
Mastering Background Sync: A Deep Dive into Offline-First Web Applications
Learn how to integrate the Background Sync API to build resilient offline-first web apps. This deep dive covers one-off and periodic sync, queueing with IndexedDB, robust retry strategies, UX patterns, fallbacks for unsupported browsers, and practical code examples.

Outcome first: by the end of this article you’ll be able to build an app that keeps working when the network drops and automatically reconciles user actions in the background once connectivity returns - with the UX polish users expect.
Why this matters. Users are mobile. Networks are flaky. Offline-first apps retain engagement and reduce frustration. Background Sync lets browsers do the heavy lifting: schedule synchronization while the app isn’t open and preserve battery and data by batching work. Read on for real code, patterns, and the tradeoffs.
What Background Sync is (and what it isn’t)
Background Sync is a Service Worker feature that allows web apps to defer actions until the browser detects a stable network. There are two flavors you should know about:
- One-off Background Sync (SyncManager) - register a one-time job that fires when connectivity returns.
- Periodic Background Sync (PeriodicSyncManager) - requests the browser run periodic background tasks on a schedule (limited support and permission-gated).
For docs and platform status, see MDN and the Google Web.Dev guide:
- Background Sync (MDN): https://developer.mozilla.org/en-US/docs/Web/API/Background_Sync_API
- Periodic Background Sync (MDN): https://developer.mozilla.org/en-US/docs/Web/API/Periodic_Background_Sync_API
- Background Sync guide (Web.Dev): https://web.dev/background-sync/
High-level architecture (quick)
- User performs an action (e.g., create post, send message) while offline.
- App persists the action to a local queue (IndexedDB).
- App registers a background sync task with the Service Worker.
- When the browser runs the sync, the Service Worker reads the queue and attempts to flush it to the server.
- On success, entries are removed; errors are retried or left for manual recovery.
Simple. Robust. Battery-friendly.
One-off Background Sync: practical implementation
When to use it: enqueue discrete user actions that must be sent eventually (outbox pattern) - tweets, chat messages, form submissions.
Main-thread: queue data and register sync
// app.js (main thread)
import { openDB } from 'https://unpkg.com/idb?module';
// open a simple DB to hold the outbox
const dbPromise = openDB('outbox-db', 1, {
upgrade(db) {
db.createObjectStore('outbox', { keyPath: 'id', autoIncrement: true });
},
});
async function enqueueRequest(payload) {
const db = await dbPromise;
await db.add('outbox', { payload, createdAt: Date.now(), attempts: 0 });
// register sync if supported
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const reg = await navigator.serviceWorker.ready;
try {
await reg.sync.register('outbox-sync');
console.log('Sync registered');
} catch (err) {
console.warn('Sync registration failed, will try on next online', err);
}
} else {
// fallback: rely on online event
console.log(
'Background Sync not supported. Falling back to online handler.'
);
}
}Service Worker: handle the sync event
// sw.js (service worker)
importScripts('https://unpkg.com/idb/build/iife/index-min.js');
const dbPromise = idb.openDB('outbox-db', 1, {
upgrade(db) {
db.createObjectStore('outbox', { keyPath: 'id', autoIncrement: true });
},
});
self.addEventListener('sync', event => {
if (event.tag === 'outbox-sync') {
event.waitUntil(flushOutbox());
}
});
async function flushOutbox() {
const db = await dbPromise;
const tx = db.transaction('outbox', 'readwrite');
const store = tx.objectStore('outbox');
const all = await store.getAll();
for (const entry of all) {
try {
// build request (idempotency token recommended)
await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entry.payload),
});
// remove on success
await store.delete(entry.id);
} catch (err) {
// increment attempts for backoff/limit
entry.attempts = (entry.attempts || 0) + 1;
await store.put(entry);
// keep processing others - don't throw to allow partial progress
}
}
await tx.done;
}Notes:
- Use an idempotency key for server requests, so retries don’t create duplicates.
- Keep per-entry attempt counters to stop retrying indefinitely, or escalate to user intervention.
Periodic Background Sync: periodic maintenance tasks
When to use it: fetch new content while the app is closed, refresh caches, or perform analytics aggregation. Periodic sync is not about flushing user actions but about keeping data fresh.
Basic registration (feature-detect and permission-check):
// main thread
if (
'serviceWorker' in navigator &&
'periodicSync' in (await navigator.serviceWorker.ready)
) {
const reg = await navigator.serviceWorker.ready;
try {
// minInterval is a suggested interval; browsers apply their own heuristics
await reg.periodicSync.register('get-latest-news', {
minInterval: 24 * 60 * 60 * 1000,
});
console.log('Periodic sync registered');
} catch (err) {
console.warn('Periodic sync registration failed', err);
}
}Service worker handles the ‘periodicsync’ event:
self.addEventListener('periodicsync', event => {
if (event.tag === 'get-latest-news') {
event.waitUntil(fetchAndCacheLatest());
}
});Caveat: Periodic background sync requires explicit user permission on some platforms and has limited support. See MDN for current status and the Permissions API details.
Storage & queueing strategies
IndexedDB is the recommended client-side store for queues because it survives reloads and supports larger payloads. Use a lightweight helper like Jake Archibald’s idb wrapper to keep your code readable: https://github.com/jakearchibald/idb
Design tips for your queue entries:
- Store the full request body, headers you need to reconstruct, and metadata (createdAt, attempts, idempotencyKey).
- Keep the payload size reasonable - large blobs should be uploaded via chunking or Background Fetch.
- Mark high-priority items so the sync worker can process them first.
Robust sync patterns
- Idempotency: include a server-side idempotency token (UUID) with each request. If the same token arrives twice, the server can dedupe.
- Batching: send multiple queued entries in one request when possible to reduce round trips and save battery.
- Exponential backoff: after a failure, increase delay between retries. Respect server 5xx vs 4xx responses.
- Partial success handling: process each queued record independently where possible; avoid aborting the entire sync on one failure.
- Limit attempts: after N failed attempts, mark entry as failed and surface to the user for manual retry.
- Conflict resolution: on sync, server may return conflicts. Decide a merge strategy (last-writer-wins, server-authoritative, or prompt user).
Example of batching and idempotency pseudo-flow inside service worker flush:
- read first 50 queue items
- build a single /api/batch POST with array + idempotency token
- if success: remove those items; if partial success: remove the ones acknowledged
UX: what the user should feel
- Optimistic UI: show the action as completed locally (e.g., “message sent”) to avoid blocking users - but visually indicate it is pending (small spinner, subtle badge).
- Queue status: surface an unobtrusive status for pending items and allow manual retry or cancel.
- Battery & data preferences: respect users’ “save data” or low-power settings. Don’t attempt big syncs on metered connections unless user asks.
- Progress notifications: when the app performs an important background sync (e.g., large file upload), use notifications or in-app indicators to confirm completion.
Progressive enhancement & fallbacks
Not all browsers support Background Sync. Progressive enhancement is essential:
- If SyncManager is missing, fall back to listening for the
onlineevent and attempt to flush the queue when the page becomes online. - Always persist to IndexedDB immediately so the queued data survives refresh and crash.
- Consider Workbox’s Background Sync plugin as a robust and tested fallback that integrates with fetch handlers: https://developers.google.com/web/tools/workbox/modules/workbox-background-sync
Example fallback outline:
window.addEventListener('online', () => {
// try flushOutbox() from the main thread if SW isn't available
});Testing and debugging sync logic
- Chrome DevTools: Application > Service Workers to inspect registrations, and the “Background Sync” pane (if available) to trigger sync events manually.
- Offline mode: Use DevTools Network throttling -> Offline to simulate network loss, perform actions, then re-enable network and trigger sync.
- Logging: use structured logging inside the service worker and main thread so events (enqueue, sync.register, sync event, errors) are traceable.
- End-to-end tests: simulate flaky networks using tools like tc (on Linux), network link conditioners, or Puppeteer with network emulation.
Security, privacy, battery and permissions
- User permission: Periodic sync may require explicit permission. One-off sync does not require permission but respects browser heuristics.
- Battery/data: Browsers will not run background sync if it would be detrimental to battery or data usage. Design with best-effort semantics.
- Sensitive data: Do not store long-lived sensitive information client-side unless encrypted and justified.
Real-world examples & when not to use Background Sync
Good fit:
- Social apps (posting, commenting)
- Messaging with outbox semantics
- Forms that must be delivered eventually
- Cache refresh / news updates via periodic sync (where supported)
Not a great fit:
- Large file uploads-use Background Fetch (different API) or chunked uploads with resumability.
- Tight latency requirements - background sync is eventual and scheduled by the browser, not immediate.
Example: integrating Workbox for a simpler developer experience
Workbox’s background sync plugin offers a higher-level queue tied to runtime caching. It handles retries and can replay failed requests automatically. Read its docs here: https://developers.google.com/web/tools/workbox/modules/workbox-background-sync
Final checklist before shipping
- Persist all actions to an outbox (IndexedDB) immediately.
- Use idempotency keys for server API endpoints.
- Implement exponential backoff and a max attempts policy.
- Provide graceful fallbacks for unsupported browsers (online event, periodic refresh when app opens).
- Surface pending status and allow user control (cancel, retry).
- Test under offline/online transitions and with varying network quality.
Conclusion
Background Sync removes roadblocks when building offline-first experiences, letting browsers coordinate energy- and data-efficient retries while your users stay productive. It’s not magic - design for retries, idempotency, and graceful degradation - but once you do, your app will feel far more reliable and polished. The strongest payoff: reduced user friction and higher completion rates for critical actions - even when the network isn’t cooperating.
Further reading and resources
- Web.Dev Background Sync Guide: https://web.dev/background-sync/
- MDN Background Sync API: https://developer.mozilla.org/en-US/docs/Web/API/Background_Sync_API
- MDN Periodic Background Sync API: https://developer.mozilla.org/en-US/docs/Web/API/Periodic_Background_Sync_API
- Workbox Background Sync: https://developers.google.com/web/tools/workbox/modules/workbox-background-sync
- idb (IndexedDB helper): https://github.com/jakearchibald/idb



