OpenPWAStore
Back to News
Guide · May 19, 2026

Background sync is an offline-preparedness workflow, not just a "try again later

Practical service worker patterns for background sync that prepare your PWA for offline scenarios before they happen.

OpenPWA Editorial3 min read
Background sync is an offline-preparedness workflow, not just a "try again later cover

Why background sync needs a workflow, not just an API call

Background sync has a reputation as "magic that fixes failed requests when network returns." In practice, it requires a deliberate workflow: queue the action, store a copy, and surface it to the client when sync runs. Without this three-part pattern, sync fails silently or leaves the app in an inconsistent state.

The sync event itself only guarantees "the browser may run your service worker soon." It doesn't guarantee your service worker gets to all pending items, or that the client page sees the result. A sync workflow is the difference between actions that actually happen and actions that look like they did.

Build a sync-first workflow, not a sync-as-fix

Treat background sync as part of every action, not an afterthought:

  1. Before the request: Store the action in IndexedDB with a pending status.
  2. Fire the request: Try the network fetch immediately.
  3. On fetch success: Mark the action as completed, clean up the queue.
  4. On fetch failure: Register a background sync event for the action type.
  5. In sync handler: Process pending actions from IndexedDB, mark completed.
  6. Notify clients: Use Client.postMessage() or the Broadcast Channel API so open pages update their UI.

The pattern works best when the action state is your source of truth. Don't rely on fetch() alone—store what happened and what's pending.

Use a sync queue with retry logic

Background sync doesn't retry automatically for you. Build a small queue system:

// IndexedDB schema for pending actions
const SYNC_QUEUE_STORE = 'syncQueue';

async function enqueueAction(action) {
  return db.add(SYNC_QUEUE_STORE, {
    id: crypto.randomUUID(),
    type: action.type,
    payload: action.payload,
    status: 'pending',
    createdAt: Date.now(),
    attemptCount: 0
  });
}

async function markActionCompleted(actionId) {
  await db.delete(SYNC_QUEUE_STORE, actionId);
}

async function getNextPendingActions(limit = 10) {
  return db.getAll(SYNC_QUEUE_STORE, IDBKeyRange.lowerBound(0), limit);
}

In the service worker sync event, process actions in batches and handle partial failures:

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

async function processSyncQueue() {
  const actions = await getNextPendingActions();
  for (const action of actions) {
    try {
      await syncAction(action);
      await markActionCompleted(action.id);
    } catch (error) {
      action.attemptCount += 1;
      if (action.attemptCount < 3) {
        await db.put(SYNC_QUEUE_STORE, action); // Retry later
      } else {
        // Log permanently failed action or surface to user
      }
    }
  }
}

Surface sync status to open windows

Clients don't automatically know that sync ran. Communicate back:

// In service worker, after sync completes
self.clients.matchAll({ type: 'window' }).then(clients => {
  clients.forEach(client => {
    client.postMessage({ type: 'sync-completed', syncedAt: Date.now() });
  });
});

// In client page
navigator.serviceWorker.addEventListener('message', (event) => {
  if (event.data.type === 'sync-completed') {
    refreshPendingActionsUI();
  }
});

This pattern lets the UI show accurate pending counts without polling the server.

Checklist: background sync readiness

Before you ship background sync:

  • [ ] Every action stores its intent in IndexedDB before or during the fetch.
  • [ ] Service worker sync event reads from a queue, not a single fetch.
  • [ ] Partial failures are retried with an attempt count limit.
  • [ ] Sync completion is posted back to open client pages.
  • [ ] Couch/fallback UI shows pending action when sync hasn't run yet.
  • [ ] Storage quota is cleared for completed actions to avoid quota limits.
  • [ ] On Safari or browsers without sync, the graceful downgrade shows immediate network failure to the user.

What this means for installed users

Installation brings higher expectations for "it works even if I lose connection." Background sync is one piece, but it must be paired with immediate offline feedback: show what's queued, surface sync status, and let the user retry manually if sync fails permanently.

Treat sync as part of your data model, not as network error fixup. When the queue is your truth, the app stays consistent whether the user is online, offline, or in-between.