← Back to DevBytes

Web Background Sync: Complete Guide

Introduction to Web Background Sync

Web Background Sync is a powerful web API that allows web applications to defer actions until the user has stable connectivity. Instead of failing when a network request cannot complete due to poor or absent connectivity, your app can schedule a "sync" event that the browser will fire once connectivity returns. This transforms flaky network experiences into resilient, app-like interactions.

Originally proposed as part of the Service Worker specification family, Background Sync is currently supported in Chromium-based browsers (Chrome, Edge, Opera, Samsung Internet). For unsupported browsers, you should provide graceful fallbacks, which we will cover later in this guide.

Why Background Sync Matters

Mobile users frequently switch between Wi-Fi, cellular networks, and offline states. Traditional web apps handle this poorly: a form submission during a subway ride simply fails, and the user must remember to retry. Native mobile apps solved this long ago with background queues. Background Sync brings the same capability to the web.

Key Benefits

Prerequisites and Setup

Background Sync requires a registered service worker and a secure context (HTTPS or localhost). You also need to register a sync event from a page script and listen for it inside the service worker. Let's start with a minimal service worker registration.

// app.js — main page script
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const reg = await navigator.serviceWorker.register('/sw.js');
      console.log('Service worker registered with scope:', reg.scope);
    } catch (err) {
      console.error('Service worker registration failed:', err);
    }
  });
}

Next, create the service worker file at the root of your site so its scope covers the entire application.

// sw.js — service worker
self.addEventListener('install', (event) => {
  self.skipWaiting();
});

self.addEventListener('activate', (event) => {
  event.waitUntil(self.clients.claim());
});

Storing Pending Work

Before requesting a sync, you must persist the work you want to perform later. IndexedDB is the recommended storage mechanism because it is asynchronous, supports structured data, and is accessible from both the page and the service worker. The example below uses a small wrapper around IndexedDB to keep a queue of pending requests.

// db.js — shared IndexedDB helper
const DB_NAME = 'sync-demo';
const STORE_NAME = 'pending-requests';

function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, 1);
    request.onupgradeneeded = () => {
      const db = request.result;
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
      }
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

export async function savePendingRequest(payload) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE_NAME, 'readwrite');
    tx.objectStore(STORE_NAME).add({
      url: payload.url,
      method: payload.method || 'POST',
      body: payload.body,
      createdAt: Date.now()
    });
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

export async function getAllPendingRequests() {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE_NAME, 'readonly');
    const req = tx.objectStore(STORE_NAME).getAll();
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

export async function deletePendingRequest(id) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE_NAME, 'readwrite');
    tx.objectStore(STORE_NAME).delete(id);
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

Registering a Sync Event

When the user performs an action that requires network access, attempt the request immediately. If it fails due to a network error, store the payload and register a sync event. The browser will fire the sync event when connectivity is restored.

// app.js — submitting a form with offline support
import { savePendingRequest } from './db.js';

async function submitPost(message) {
  try {
    const res = await fetch('/api/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message })
    });
    if (!res.ok) throw new Error('Server error');
    console.log('Posted immediately');
  } catch (err) {
    // Network failed — store and schedule sync
    await savePendingRequest({
      url: '/api/posts',
      method: 'POST',
      body: JSON.stringify({ message })
    });

    if ('SyncManager' in window) {
      const reg = await navigator.serviceWorker.ready;
      await reg.sync.register('sync-posts');
      console.log('Sync registered. Will retry when online.');
    } else {
      // Fallback: retry on the next 'online' event
      window.addEventListener('online', () => {
        submitPost(message);
      }, { once: true });
      console.log('Background Sync unsupported. Will retry when back online.');
    }
  }
}

The string passed to sync.register() is a tag. Tags are unique identifiers; registering the same tag twice will not create duplicate sync events. If multiple pending actions share a tag, only one sync event fires, and your service worker should drain the entire queue.

Handling the Sync Event in the Service Worker

The actual work happens inside the service worker. Listen for the sync event, match the tag, and replay the stored requests. Use event.waitUntil() to keep the service worker alive until all work completes. If the promise rejects, the browser will retry the sync later with exponential backoff.

// sw.js — handling sync events
import { getAllPendingRequests, deletePendingRequest } from './db.js';

self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-posts') {
    event.waitUntil(replayPendingRequests());
  }
});

async function replayPendingRequests() {
  const pending = await getAllPendingRequests();
  await Promise.all(pending.map(async (item) => {
    try {
      const res = await fetch(item.url, {
        method: item.method,
        headers: { 'Content-Type': 'application/json' },
        body: item.body
      });
      if (!res.ok) throw new Error('Request failed: ' + res.status);
      await deletePendingRequest(item.id);
      await notifyClients({ status: 'success', id: item.id });
    } catch (err) {
      console.error('Replay failed for item', item.id, err);
      // Throwing here causes the browser to retry the sync later
      throw err;
    }
  }));
}

async function notifyClients(message) {
  const clients = await self.clients.matchAll({ includeUncontrolled: true });
  clients.forEach((client) => client.postMessage(message));
}

Using ES module imports in a service worker requires the type option during registration: navigator.serviceWorker.register('/sw.js', { type: 'module' }). If you need broader browser support, bundle your service worker with a tool like Workbox or esbuild instead.

Periodic Background Sync

The related Periodic Background Sync API lets your app wake up at intervals to refresh content, even when the user is not actively using the site. This is ideal for news apps, email, or dashboards. Periodic sync requires a installed PWA (added to home screen) and explicit user permission.

// app.js — registering periodic sync
async function registerPeriodicSync() {
  if ('serviceWorker' in navigator && 'periodicSync' in ServiceWorkerRegistration.prototype) {
    try {
      const reg = await navigator.serviceWorker.ready;
      const status = await navigator.permissions.query({ name: 'periodic-background-sync' });
      if (status.state !== 'granted') {
        console.warn('Periodic sync permission not granted');
        return;
      }
      await reg.periodicSync.register('refresh-content', {
        minInterval: 24 * 60 * 60 * 1000 // 24 hours
      });
      console.log('Periodic sync registered');
    } catch (err) {
      console.error('Periodic sync registration failed:', err);
    }
  }
}
// sw.js — handling periodic sync
self.addEventListener('periodicsync', (event) => {
  if (event.tag === 'refresh-content') {
    event.waitUntil(refreshContent());
  }
});

async function refreshContent() {
  try {
    const cache = await caches.open('content-cache');
    const res = await fetch('/api/latest');
    if (res.ok) {
      await cache.put('/latest', res.clone());
      await notifyClients({ status: 'content-updated' });
    }
  } catch (err) {
    console.error('Periodic refresh failed:', err);
  }
}

Best Practices

Debugging Tips

Chrome DevTools provides excellent tooling for Background Sync. Under Application > Service Workers, you can manually dispatch a sync event by clicking "sync" next to a registered tag, which is invaluable for testing without waiting for real network changes. The Application > IndexedDB panel lets you inspect your pending queue. Use chrome://serviceworker-internals/ for deeper inspection of service worker lifecycle events.

Conclusion

Web Background Sync bridges the gap between native and web applications by giving developers a reliable mechanism to defer and retry network operations. By combining a service worker, IndexedDB persistence, and the SyncManager API, you can build web experiences that survive poor connectivity gracefully and feel dependable to users. Pair it with Periodic Background Sync for content refresh, always provide fallbacks for unsupported browsers, and follow idempotency best practices to ensure your replay logic is robust. With these patterns in place, your web app will deliver a resilient, app-like experience regardless of network conditions.

— Ad —

Google AdSense will appear here after approval

← Back to all articles