Swoff Swoff

Background Sync

Use the Background Sync API to retry requests when connection returns — even after the user closes the tab.

Background Sync

Status: Available Pattern

Background Sync lets your service worker process the mutation queue even after the user closes the app.

What is Background Sync?

A browser API that:

  • Registers a "sync" event tag in the SW from your client code
  • Fires the SW's sync event when the browser detects a stable connection
  • Works even if the user closed the tab
  • Sync events are one-time — must re-register after each sync

Note: Swoff covers the client-side registration and SW handler. Your backend receives the synced requests.

When to Use This

ScenarioUse Background Sync?
User creates data and closes the tab✅ Yes — sync happens in background
User switches to another app✅ Yes — sync when connection stabilizes
User has unstable network✅ Yes — retries automatically
You only need sync while app is open❌ No — use Mutation Queuing's online event alone
Firefox / Safari users❌ Not supported — falls back to online event

Browser Support

BrowserSupport
Chrome✅ Yes
Edge✅ Yes
Firefox❌ No (under consideration)
Safari❌ No (not on roadmap)

Fallback: For unsupported browsers, the mutation queue auto-processes on the online event (already set up in Mutation Queuing). The fallback only works while the tab is open.

Prerequisites

  1. Mutation Queuing — the queue must be set up first
  2. SW Template — you'll add a sync event handler
  3. Client Registration — SW must be registered

Implementation

Client: Register Sync After Queuing

Create swoff/background-sync.js:

swoff/background-sync.js
import { queueMutation, processMutationQueue, getPendingCount } from "./mutation-queue.js";

const SYNC_TAG = "sync-mutations";

async function registerSync() {
  if (!("serviceWorker" in navigator) || !("SyncManager" in window)) {
    // Fallback: process when tab comes online
    window.addEventListener("online", processMutationQueue, { once: true });
    return;
  }

  try {
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register(SYNC_TAG);
  } catch {
    // Registration failed — fallback to online event
    window.addEventListener("online", processMutationQueue, { once: true });
  }
}

/**
 * Queue a mutation and try background sync.
 * Falls back to online event listener in unsupported browsers.
 * @param {object} mutation - { method, url, body, headers }
 */
export async function syncWhenPossible(mutation) {
  await queueMutation(mutation);
  await registerSync();
}

Service Worker: Handle Sync Event

The SW needs its own queue processor since it has no DOM access. Add this to swoff/sw-template.js:

swoff/sw-template.js (appended)
// Background sync — process mutation queue
self.addEventListener("sync", (event) => {
  if (event.tag === "sync-mutations") {
    event.waitUntil(processMutationQueueInSW());
  }
});

const SW_DB_NAME = "swoff-queue";
const SW_STORE_NAME = "mutations";
const SW_MAX_RETRIES = 5;

function openSWQueueDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(SW_DB_NAME, 1);
    request.onupgradeneeded = (e) => {
      const db = e.target.result;
      if (!db.objectStoreNames.contains(SW_STORE_NAME)) {
        const store = db.createObjectStore(SW_STORE_NAME, { keyPath: "id" });
        store.createIndex("by-timestamp", "timestamp");
      }
    };
    request.onsuccess = (e) => resolve(e.target.result);
    request.onerror = (e) => reject(e.target.error);
  });
}

async function processMutationQueueInSW() {
  let succeeded = 0;
  let failed = 0;
  const tagsToInvalidate = new Set();

  try {
    const db = await openSWQueueDB();
    const tx = db.transaction(SW_STORE_NAME, "readonly");
    const store = tx.objectStore(SW_STORE_NAME);
    const index = store.index("by-timestamp");
    const queue = await new Promise((resolve, reject) => {
      const request = index.getAll();
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });

    for (const item of queue) {
      if (item.retryCount >= SW_MAX_RETRIES) {
        await removeFromSWQueue(item.id);
        failed++;
        continue;
      }
      try {
        const response = await fetch(item.url, {
          method: item.method,
          headers: {
            "Content-Type": "application/json",
            ...item.headers,
          },
          body: JSON.stringify(item.body),
        });
        if (!response.ok) throw new Error(`HTTP ${response.status}`);

        const serverData = await response.json();

        // Reconcile: update local record with server response
        if (item.tempId && item.storeName) {
          await reconcileRecordInSW(item.storeName, item.tempId, serverData);
        }

        // Collect tags for invalidation
        if (item.tags) {
          item.tags.forEach((tag) => tagsToInvalidate.add(tag));
        }

        await removeFromSWQueue(item.id);
        succeeded++;
      } catch {
        item.retryCount++;
        await updateInSWQueue(item);
        failed++;
      }
    }
  } catch (err) {
    console.error("Background sync failed:", err);
  }

  // Invalidate cached responses
  for (const tag of tagsToInvalidate) {
    await invalidateByTag(tag);
  }

  // Notify all clients so they can dispatch window events
  const clients = await self.clients.matchAll();
  for (const client of clients) {
    client.postMessage({
      type: "BACKGROUND_SYNC_COMPLETE",
      detail: { succeeded, failed, tags: [...tagsToInvalidate] },
    });
  }
}

// SW-side reconcile — same logic as client-side reconcileRecord,
// but uses the shared app IndexedDB directly (no DOM needed)
async function reconcileRecordInSW(storeName, tempId, serverData) {
  const db = await openAppDB(storeName);
  const tx = db.transaction(storeName, "readwrite");
  const store = tx.objectStore(storeName);

  const existing = await new Promise((resolve, reject) => {
    const request = store.get(tempId);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
  if (!existing) return;

  const reconciled = {
    ...existing,
    ...serverData,
    id: serverData.id,
    $synced: true,
    $syncedAt: Date.now(),
  };

  store.put(reconciled);
  if (String(tempId) !== String(serverData.id)) {
    store.delete(tempId);
  }

  await new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

// Open the app's main IndexedDB to reconcile records
// Change DB_NAME to match your app's database name
async function openAppDB(storeName) {
  const APP_DB_NAME = "my-app"; // ← Change to your app's DB name
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(APP_DB_NAME);
    request.onsuccess = (e) => resolve(e.target.result);
    request.onerror = (e) => reject(e.target.error);
  });
}

async function removeFromSWQueue(id) {
  const db = await openSWQueueDB();
  const tx = db.transaction(SW_STORE_NAME, "readwrite");
  tx.objectStore(SW_STORE_NAME).delete(id);
  await new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

async function updateInSWQueue(item) {
  const db = await openSWQueueDB();
  const tx = db.transaction(SW_STORE_NAME, "readwrite");
  tx.objectStore(SW_STORE_NAME).put(item);
  await new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

Important: The SW accesses the same IndexedDB database (swoff-queue) as the client code. Both must use the same DB_NAME and STORE_NAME. The onupgradeneeded handler is a no-op if the database already exists, so it's safe to include in both places.

SW Template Changes Summary

Add these blocks to your existing swoff/sw-template.js:

BlockLocationPurpose
self.addEventListener("sync", ...)Anywhere in fileListen for background sync events
openSWQueueDB(), processMutationQueueInSW(), helpersAfter event listenersQueue processing, reconcile, invalidate, client notification
reconcileRecordInSW()After helpersServer response → local record update
openAppDB()After helpersOpen app's IndexedDB for reconciliation

The SW template's fetch handler already lets mutations pass through (see isReadRequest in SW Template). No changes needed there. The tag-indexed caching and INVALIDATE_TAG handler are added separately — see SW Template.

Client: Handle SW Sync Completion

When the SW processes the queue in background (tab may be closed), the client needs to pick up the results when the user returns. Add this listener in your app init:

swoff/sw-injector.js (appended)
// Listen for background sync results from the SW
navigator.serviceWorker.addEventListener("message", (event) => {
  if (event.data.type === "BACKGROUND_SYNC_COMPLETE") {
    const { succeeded, failed, tags } = event.data.detail;

    window.dispatchEvent(
      new CustomEvent("mutation-sync-complete", {
        detail: { succeeded, failed },
      }),
    );

    if (tags && tags.length > 0) {
      window.dispatchEvent(
        new CustomEvent("cache-invalidated", { detail: { tags } }),
      );
    }

    window.dispatchEvent(new CustomEvent("mutation-queue-changed"));
  }
});

This ensures that even if the background sync happens while the tab is closed, when the user returns the UI updates correctly — stale data is refetched, indicators update.

Re-registration Pattern

Sync events are one-time — after the SW processes the queue, it won't fire again until a new sync.register() call. If the queue still has items after processing (e.g., some failed), re-register:

swoff/background-sync.js
// Append to swoff/background-sync.js

/**
 * Re-register for background sync if there are still pending mutations.
 */
export async function retrySync() {
  if (!("serviceWorker" in navigator) || !("SyncManager" in window)) return;
  const count = await getPendingCount();
  if (count > 0) {
    await registerSync();
  }
}

// Auto-retry after each sync completes
window.addEventListener("mutation-sync-complete", retrySync);

Fallback Strategy

The syncWhenPossible() implementation above already handles unsupported browsers — it falls back to the online event listener. For browsers that support Background Sync but where registration fails, it also falls back.

The mutation queue's primary online event listener (set up in swoff/mutation-queue.js) serves as a secondary fallback for any queued items not caught by background sync.

No additional code needed — the client implementation above covers all cases:

BrowserBehavior
Chrome / EdgeBackground sync — works even after tab close
Firefox / Safarionline event listener — works while tab is open
Any (sync registration fails)online event listener — works while tab is open

Limitations

  1. No payload — can't pass data to sync event (must store in IDB)
  2. No guarantee — browser decides when to fire
  3. One-time — must re-register after each sync
  4. Browser support — Chrome/Edge only (Firefox/Safari fall back to online event)
  5. SW lifespan — the SW can be terminated at any time; long-running sync may be interrupted

Integration Checklist

  • Add sync event listener + processMutationQueueInSW() + reconcileRecordInSW() + openAppDB() to swoff/sw-template.js
  • Create swoff/background-sync.js with syncWhenPossible() + support detection
  • Add BACKGROUND_SYNC_COMPLETE message listener in your app init (e.g., sw-injector.js)
  • Replace direct queueMutation() calls with syncWhenPossible() where background sync is desired
  • (Optional) Add retrySync() + mutation-sync-complete listener for persistent retry
  • Update openAppDB(storeName) in SW to match your app's IndexedDB database name
  • Test in Chrome/Edge (background sync works after tab close)
  • Test in Firefox/Safari (falls back to online event while tab is open)
  • Your backend receives the synced requests

Status

  • Background Sync support detection
  • Client-side registration wrapper (syncWhenPossible())
  • Fallback for unsupported browsers (Firefox, Safari)
  • SW sync event handler (processMutationQueueInSW())
  • SW-side reconcile + cache invalidation
  • Client notification on background sync completion
  • Re-registration pattern for persistent retry
  • Integration with mutation queue (shared swoff-queue database)
  • Framework adapters — reuse useMutationQueue from Mutation Queuing

Next Steps

On this page