Automatically replaying offline actions
A form gets submitted right when the connection drops. Instead of showing an error and discarding the input, the Background Sync API remembers the action and reliably replays it once the network returns, even if the tab has long been closed.
Table of Contents
- 1. The problem: offline actions otherwise simply get lost
- 2. Registering a sync task
- 3. The outbox: queuing actions in IndexedDB
- 4. Handling the sync event in the service worker
- 5. Browser support and feature detection
- 6. Distinguishing it from Periodic Background Sync
- 7. Conflict handling: what can happen with delayed delivery
- 8. User feedback: transparency about pending actions
- 9. Conclusion: reliability instead of an error message
- 10. Summary
- 11. FAQ
1. The problem: offline actions otherwise simply get lost
Without Background Sync, web apps have exactly two bad options when a user triggers an action while offline: either fail the request immediately and show an error, or silently try to send it via fetch() and hope the promise eventually resolves. Both lead to frustration, especially for mobile users with unstable connections in elevators, tunnels, or rural areas.
The Background Sync API solves this structurally: instead of sending the request directly, the intent gets registered, for example 'submit this form', and the browser takes responsibility for delivering that intent once a connection exists again, even if the user has long since left the page. The operating system wakes the service worker for this, independent of the tab's lifecycle.
2. Registering a sync task
The flow starts on the main thread: instead of calling fetch() directly, the action is first saved locally, usually in IndexedDB, and then a sync tag is registered with the service worker via registration.sync.register(). This tag is a freely chosen string that later identifies, inside the service worker, which kind of action needs to be replayed.
It's important to note that register() itself gives no guarantee of immediate execution. If a connection already exists, the sync event usually fires within a few seconds; if not, the browser waits until the operating system reports a stable connection, and it may well factor in battery and network heuristics that developers cannot influence directly.
// app.js: submit a form in an offline-safe way
async function submitComment(formData) {
await saveToOutbox(formData); // IndexedDB, see next section
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const registration = await navigator.serviceWorker.ready;
try {
await registration.sync.register('sync-comments');
} catch (err) {
// Background Sync unavailable -> immediate fallback attempt
await trySendDirectly(formData);
}
} else {
await trySendDirectly(formData);
}
}
3. The outbox: queuing actions in IndexedDB
Background Sync itself carries no data, it merely delivers a 'sync now' signal. The actual payload, such as the form content or the like action, must therefore be stored beforehand in persistent storage reachable from both the main thread and the service worker. IndexedDB is the obvious choice here, since localStorage is not available in the service worker context.
A common pattern is an outbox: an object store holds pending actions with status 'pending', the service worker reads all open entries when the sync event fires, tries to send them, and marks successful entries as done or removes them outright. This keeps state consistent even if several sync attempts are needed in between.
// outbox.js: simple IndexedDB outbox
const DB_NAME = 'outbox-db';
const STORE = 'pending-comments';
function openOutbox() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => {
req.result.createObjectStore(STORE, { keyPath: 'id', autoIncrement: true });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function saveToOutbox(data) {
const db = await openOutbox();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).add({ ...data, createdAt: Date.now() });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
4. Handling the sync event in the service worker
When the sync event fires, event.tag carries the registered tag, so a service worker can distinguish several different sync tasks, for example 'sync-comments' for comments and 'sync-likes' for like clicks. The handler reads all open entries from the outbox, sends them sequentially or in parallel to the server, and removes successfully processed entries.
Just as with the push event, event.waitUntil() must be used here too, so the service worker isn't terminated before all pending requests have completed. If sending fails again, for instance because the connection drops right away, the promise should be rejected with an error, because the browser then automatically schedules another attempt with exponential backoff.
// sw.js
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-comments') {
event.waitUntil(flushOutbox());
}
});
async function flushOutbox() {
const pending = await getAllFromOutbox();
for (const item of pending) {
try {
const res = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
});
if (!res.ok) throw new Error('Server rejected the comment');
await removeFromOutbox(item.id);
} catch (err) {
// Don't remove from outbox -> next sync attempt retries it
throw err;
}
}
}
5. Browser support and feature detection
The Background Sync API is currently limited to Chromium-based browsers, Safari and Firefox don't support it. That makes clean feature detection mandatory: before every register() call you must check whether both 'serviceWorker' in navigator and 'SyncManager' in window are present, otherwise the call throws an exception.
For unsupported browsers you need a fallback that still replays the action eventually, for example your own online event listener that manually tries to send pending outbox entries once the connection returns. This keeps the core functionality working even without the native API, though without the reliability of operating-system-level integration.
// Fallback for browsers without Background Sync
function registerFallbackSync() {
window.addEventListener('online', async () => {
const pending = await getAllFromOutbox();
for (const item of pending) {
await trySendDirectly(item);
}
});
}
const supportsBackgroundSync =
'serviceWorker' in navigator && 'SyncManager' in window;
if (!supportsBackgroundSync) {
registerFallbackSync();
}
6. Distinguishing it from Periodic Background Sync
Alongside the regular Background Sync API, there's a related but much more restrictive Periodic Background Sync API that allows periodic background updates, for example fetching new content every few hours. It solves a different problem: not replaying a specific user action, but proactively refreshing data without the user having opened the app.
Periodic Background Sync additionally requires that the web app be installed and reach a certain 'site engagement score', which makes it considerably less reliable for most projects than the regular Background Sync API. For replaying concrete user actions such as form submissions, the one-shot, event-driven Background Sync API remains the right choice.
7. Conflict handling: what can happen with delayed delivery
If an action is only replayed hours after the actual user input, the surrounding conditions may have changed in the meantime: a like target no longer exists, a cart price has changed, or the user has since logged out. The server endpoint for replayed actions should therefore be written defensively and return clear error codes for such cases.
On the client side, it's advisable to timestamp every outbox entry and, when processing it in the service worker, check whether the action still makes sense at all, for example via an expiry date. A like set offline three days ago should, when in doubt, be discarded rather than silently delivered hours after the original click when the context is no longer relevant to the user.
8. User feedback: transparency about pending actions
From the user's perspective, it matters to make visible that an action was accepted but not yet finally delivered. A simple UI signal, such as a small cloud icon reading 'will be sent once you're back online' next to the comment, prevents confusion and repeated submission of the same action out of impatience.
The service worker can notify open tabs via postMessage() when an outbox entry has been processed successfully, so the UI can update in real time if the user still has the page open. If the page is closed, a reconciliation between local and server state on the next visit takes care of updating the display.
// sw.js: inform tabs about successful sync
async function notifyClients(message) {
const allClients = await clients.matchAll({ type: 'window' });
for (const client of allClients) {
client.postMessage(message);
}
}
// after a successful send inside flushOutbox():
await notifyClients({ type: 'sync-success', id: item.id });
9. Conclusion: reliability instead of an error message
The Background Sync API shifts responsibility for reliable delivery away from the user and toward the browser: instead of manually retrying a failed request, the app remembers the intent and the browser guarantees delivery as soon as the network situation allows it. Combined with an IndexedDB outbox, this produces robust offline-first behavior without complex custom retry logic.
Because of the limited browser support, a fallback for Safari and Firefox remains essential, and developers should always make it transparent to users that an action is still pending. The table below summarizes the key building blocks.
| Building block | Location | Task | Key detail |
|---|---|---|---|
| Outbox (IndexedDB) | Client + service worker | Persist the payload | localStorage doesn't work in a service worker |
| sync.register() | Client | Register the sync task | No guarantee of immediate execution |
| sync event | Service worker | Process pending entries | event.waitUntil() is mandatory |
| Failed send | Service worker | Automatic retry | Rejecting the promise triggers another attempt |
Mironsoft
Modern browser APIs, performance, and maintainable JavaScript
JavaScript that holds up in the real browser, not just in the tutorial?
We review existing frontend code for outdated patterns, unnecessary dependencies, and performance traps, then replace them with modern, native browser APIs that mean less bundle weight and less maintenance burden.
Code Review
Systematically finding outdated patterns, unnecessary dependencies, and memory leaks.
Performance Optimization
Improving bundle size, load time, and runtime performance with modern APIs.
Modernization
Deliberately introducing native browser APIs instead of heavy libraries.
10. Summary
Background Sync API: The Key Facts at a Glance
Signal, not data
Background Sync only delivers the 'sync now' signal, data lives in IndexedDB
Registration
registration.sync.register() registers a sync task with a freely chosen tag
Processing
Service worker reads the outbox on the sync event and replays pending entries
Support
Chromium browsers only, fallback via the online event needed for Safari and Firefox