Service workers give web applications a place to cache resources, intercept requests, receive push notifications, and continue selected work when the page is no longer open. One of the APIs that builds on this model is the Background Synchronization API.
One-off Background Sync is still a limited-availability feature in 2026: it works in Chromium-based browsers, but not in Safari or Firefox. For that reason, the safest way to use it today is as a progressive enhancement. If the browser supports ServiceWorkerRegistration.sync, register a background sync task. Otherwise, fall back to a foreground-triggered sync while the app is open.
Usage ¶
Background Sync is a very small API. The name is slightly misleading because the browser does not synchronize your data for you. Your application still has to decide what to upload, what to fetch again, and how to resolve conflicts.
What the API gives you is a way to defer that work to the service worker until the browser considers the network available again. That makes it more useful than plain online/offline events when the app is in the background or no tab is currently visible.
Background Sync is useful for a web application that runs on a mobile device, where you sometimes have a flaky or no internet connection.
The API consists of the SyncManager interface and the sync event. It is only available in a secure context, which means HTTPS in production.
In the foreground script, the application asks the browser to fire an event as soon as connectivity is available. In a production application, I recommend checking support first and providing a fallback for browsers without sync support.
async function requestTodoSync() {
const registration = await navigator.serviceWorker.ready;
if ('sync' in registration) {
const tags = await registration.sync.getTags();
if (!tags.includes('todo_updated')) {
await registration.sync.register('todo_updated');
}
return;
}
registration.active?.postMessage({ type: 'trigger_sync' });
}
In the service worker, register a listener for the sync event and use the same sync function for the fallback message.
self.addEventListener('sync', event => {
if (event.tag === 'todo_updated') {
event.waitUntil(runSync());
}
});
self.addEventListener('message', event => {
if (event.data?.type === 'trigger_sync') {
event.waitUntil(runSync());
}
});
When the device is online, register() usually leads to a sync event quickly. When the device is offline, the browser keeps the request and fires the event later.
Always wrap the work in event.waitUntil(). This tells the browser that asynchronous work is still in progress and that the service worker should stay alive until the promise settles.
The method that performs the synchronization has to return a promise. If it fulfills, the sync completed successfully. If it rejects, the browser may retry later. The retry policy is browser-managed, so you should treat retry timing as an implementation detail and not rely on exact delays.
The argument you pass to register() is called the tag name. It identifies a pending sync request. If the same tag is registered repeatedly while a request is still pending, the browser coalesces those requests. That behavior is useful for outbox-style workloads where you only need one pending sync job per logical queue.
In the sync listener, your application receives a SyncEvent. The most important attributes are:
tag: a string containing the name of the tag. Same value as the argument passed to theregister()method.lastChance: a boolean attribute. If true, the browser is not going to make any further attempts if the current attempt fails.
Demo application ¶
The demo is a simple to-do application built with Ionic / Angular on the client and Spring Boot on the server. The client stores local changes in IndexedDB first and then asks the service worker to synchronize them with the backend.
Angular has service worker support, but this example uses a custom service worker with Workbox because the application needs its own synchronization logic and a manual fallback path for browsers without one-off Background Sync.
Each time the user inserts, updates, or deletes a to-do item, the client stores the change in IndexedDB and requests a sync operation.
deleteTodo(todo: Todo): void {
todo.ts = -1;
this.db.todos.put(todo).then(() => this.requestSync());
}
async save(todo: Todo): Promise<void> {
if (!todo.id) {
todo.id = uuidv4();
todo.ts = 0;
this.db.todos.add(todo).then(() => this.requestSync());
} else {
const oldTodo = await this.db.todos.get(todo.id);
if (this.changed(oldTodo, todo)) {
todo.ts = Date.now();
this.db.todos.put(todo).then(() => this.requestSync());
}
}
}
async requestSync(): Promise<void> {
if (!('serviceWorker' in navigator)) {
return;
}
const swRegistration = await navigator.serviceWorker.ready;
const syncManager = swRegistration.sync as SyncManager | undefined;
if (syncManager) {
const tags = await syncManager.getTags();
if (!tags.includes(SYNC_TAG)) {
await syncManager.register(SYNC_TAG);
}
return;
}
swRegistration.active?.postMessage({type: TRIGGER_SYNC_MESSAGE});
}
In supported browsers, the service worker runs that work in response to the sync event. In unsupported browsers, the page sends a fallback message to the service worker, which calls the same synchronization function.
self.addEventListener('sync', event => {
const syncEvent = event as SyncEventWithTag;
if (syncEvent.tag === SYNC_TAG) {
syncEvent.waitUntil(runSync());
}
});
self.addEventListener('message', event => {
const messageEvent = event as ExtendableMessageEvent;
if (messageEvent.data?.type === TRIGGER_SYNC_MESSAGE) {
messageEvent.waitUntil(runSync());
}
});
function runSync(): Promise<void> {
if (!currentSync) {
currentSync = serverSync().finally(() => {
currentSync = undefined;
});
}
return currentSync;
}
IndexedDB is available both in the window context and inside the service worker, which makes it a good fit for this pattern. localStorage is not available in service workers, so it cannot be used for shared client-side state here. I use Dexie.js as a small wrapper around IndexedDB to keep the code manageable.
Here's an overview of the different parts of the application and how they play together:
The example uses a simple synchronization algorithm based on the description from this blog post: https://coderwall.com/p/gt_rfa/simple-data-synchronisation-for-web-mobile-apps-working-offline
This implementation is intentionally small and focused on the synchronization flow. It is not meant to be a generic synchronization framework. For the sake of brevity, I do not explain the algorithm in detail here. The linked article above covers the idea, and if you have questions about my implementation, send me a message.
You can find the complete source code for the client and the server on GitHub: https://github.com/ralscha/blog/tree/master/background-sync
To run the example, you first have to start the server.
cd server
./mvnw spring-boot:run
//or on Windows
.\mvnw.cmd spring-boot:run
Then install the client and start it. You need to have Node.js, and the Ionic CLI installed.
cd client
npm install
npm run dev
Links ¶
You can find more information about the Background Sync API in the following resources:
https://developer.mozilla.org/en-US/docs/Web/API/Background_Synchronization_API https://developer.mozilla.org/en-US/docs/Web/API/SyncManager https://caniuse.com/background-sync https://wicg.github.io/BackgroundSync/spec/ https://github.com/WICG/background-sync/blob/main/explainers/sync-explainer.md