Background Sync API: Automatically Replaying Offline Actions
AI generated
JS
() =>
JavaScript · Browser APIs · Offline-First
Background Sync API
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.

16 min read Background Sync API Service Worker Offline-First IndexedDB

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

11. FAQ: Background Sync API: The Key Facts at a Glance

1What exactly does the Background Sync API do?
It registers an intent, such as 'submit this form', and guarantees the service worker is woken once a network connection exists again, even if the tab has already been closed.
2Does Background Sync itself transport data?
No. The API only delivers a signal to the service worker. The actual payload must be stored beforehand in persistent storage such as IndexedDB.
3Why isn't localStorage suitable as an outbox?
localStorage is unavailable in the service worker context and is also synchronous, which would block the service worker's event loop model. IndexedDB is asynchronous and reachable from both contexts.
4What happens if the send fails again inside the sync event?
If the promise in the handler is rejected, the browser automatically schedules another attempt with exponential backoff, without the app having to implement that itself.
5Which browsers support Background Sync?
Currently only Chromium-based browsers like Chrome and Edge. Safari and Firefox don't support the API, which is why a fallback via the online event is needed.
6What is the difference from Periodic Background Sync?
Periodic Background Sync proactively refreshes data at regular intervals, while the regular Background Sync API replays a specific, one-time user action.
7How does the user find out an action is still pending?
Through a custom UI signal, such as an icon with a hint text, making it visible that the action was accepted but not yet delivered.
8Can the service worker inform the open page about a successful sync?
Yes, via clients.matchAll() and postMessage() the service worker can notify open tabs in real time as soon as an outbox entry has been processed successfully.
9What should be done with stale outbox entries?
Every entry should get a timestamp so the service worker can check, when processing, whether the action is still relevant, and discard stale entries when in doubt.
10Is Background Sync suitable for security-critical actions?
For non-critical actions like comments or likes, yes. For payment-related processes, the server should add extra validation and idempotency checks, since the exact delivery time isn't predictable.