A commuter on a subway platform taps “Place order” as the train enters a tunnel. The fetch call drops mid-flight, the app shows a spinner that never resolves, and the order vanishes with no record on the client or the server — the user assumes it went through and never retries. Multiply that by a transit system’s daily ridership and the silent-loss rate becomes a measurable revenue leak, not an edge case. The inverse failure is just as expensive: a bad deploy ships a service worker that caches a broken bundle, skipWaiting never fires because tabs are never closed, and thousands of returning users keep loading the broken version for days while dashboards show a clean rollout. Both failures share a root cause — treating the service worker and its storage as an implementation detail instead of a durability layer with its own recovery contract.

This section builds on the client-side durability model established in Session State Persistence and Hydration Fallbacks; offline recovery extends that durability across process boundaries, tab closures, and full network outages, not just in-memory crashes.

Architecture overview #

Every offline-recovery failure on a production PWA traces back to one of five failure domains, all of which run inside — or communicate directly with — the service worker’s ServiceWorkerGlobalScope and its two storage primitives, Cache Storage and IndexedDB. Registration and lifecycle bugs strand users on stale code. Data-sync bugs corrupt or drop writes made while offline. Background-replay bugs silently discard queued mutations. Precache and routing bugs turn a missing asset into a blank screen instead of a fallback page. And telemetry gaps mean all four of the above fail invisibly, with no signal reaching an engineer until a support ticket does.

PWA offline-recovery failure domain map A central Service Worker scope box sits between a Cache Storage box and an IndexedDB box. Four surrounding boxes — lifecycle and registration recovery, offline-first data sync, background sync replay queues, and app shell precache and fallback routing — each connect into the Service Worker scope. A fifth box, service worker error telemetry and debugging, sits below and receives an arrow from the Service Worker scope, representing that all failures surface through the same global error listeners. Service Worker Lifecycle & Registration Recovery Offline-First Data Sync & Conflict Resolution Cache Storage precached shell + offline document Service Worker ServiceWorkerGlobalScope install / activate / fetch / sync IndexedDB outbox + replay queue records Background Sync & Request Replay Queues App Shell Precache & Offline Fallback Routing Service Worker Error Telemetry & Debugging

The five sections below correspond to the five domains in the diagram — each is a distinct failure surface with its own interception point, its own storage contract, and its own recovery path back to a consistent user experience.

Service Worker Lifecycle and Registration Recovery #

The single most common production incident in this section is the “waiting trap”: a new service worker installs successfully and sits in the waiting state indefinitely because the browser will not activate it while any open tab is still controlled by the previous version, and most users never fully close every tab. Engineers ship a fix, watch the deploy dashboard go green, and then spend a day fielding reports from users who are still running the broken build. Service Worker Lifecycle and Registration Recovery treats registration as a stateful process with explicit transitions, not a fire-and-forget call.

The registration wrapper below surfaces every lifecycle transition, detects the waiting state deterministically, and drives skipWaiting from an explicit user action rather than silently swapping code under an active session:

export interface SwUpdateHooks {
  onUpdateAvailable: (registration: ServiceWorkerRegistration) => void;
  onControllerChange: () => void;
  onRegistrationError: (error: unknown) => void;
}

let reloadingGuard = false;

export async function registerServiceWorker(
  scriptUrl: string,
  hooks: SwUpdateHooks
): Promise<ServiceWorkerRegistration | null> {
  if (!('serviceWorker' in navigator)) return null;

  try {
    const registration = await navigator.serviceWorker.register(scriptUrl, {
      scope: '/',
    });

    // A worker already sitting in "waiting" from a previous session
    if (registration.waiting) hooks.onUpdateAvailable(registration);

    registration.addEventListener('updatefound', () => {
      const incoming = registration.installing;
      if (!incoming) return;

      incoming.addEventListener('statechange', () => {
        // "installed" + an existing controller means an update is waiting,
        // not a first install — first installs have no controller yet
        if (incoming.state === 'installed' && navigator.serviceWorker.controller) {
          hooks.onUpdateAvailable(registration);
        }
      });
    });

    // Fires once the new worker calls skipWaiting and takes control
    navigator.serviceWorker.addEventListener('controllerchange', () => {
      if (reloadingGuard) return; // Prevent a reload loop across tabs
      reloadingGuard = true;
      hooks.onControllerChange();
      window.location.reload();
    });

    return registration;
  } catch (error) {
    hooks.onRegistrationError(error);
    return null;
  }
}

// Called from a user-facing "Update available" toast, never automatically
export function applyWaitingUpdate(registration: ServiceWorkerRegistration): void {
  registration.waiting?.postMessage({ type: 'SKIP_WAITING' });
}

Inside the worker script itself, skipWaiting must only run in response to that explicit message — calling it unconditionally in the install handler is what causes half-loaded pages to swap scripts mid-session and throw reference errors against DOM nodes the new script no longer expects.

Offline-First Data Sync and Conflict Resolution #

Once a write can happen while offline, the client owns a durability problem the server used to own alone: two devices editing the same record while disconnected, or a single device submitting the same mutation twice after a retry. Offline-First Data Sync and Conflict Resolution treats every offline write as a queued, versioned intent rather than a fire-and-forget request, using an IndexedDB outbox as the durability boundary.

interface OutboxEntry {
  id: string;
  url: string;
  method: 'POST' | 'PUT' | 'PATCH';
  body: unknown;
  baseVersion: number; // Version the client believed was current at edit time
  enqueuedAt: number;
}

const DB_NAME = 'offline-outbox';
const STORE = 'entries';

async function openOutboxDb(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, 1);
    req.onupgradeneeded = () => {
      req.result.createObjectStore(STORE, { keyPath: 'id' });
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

export async function enqueueWrite(entry: Omit<OutboxEntry, 'id' | 'enqueuedAt'>): Promise<void> {
  const db = await openOutboxDb();
  const full: OutboxEntry = { ...entry, id: crypto.randomUUID(), enqueuedAt: Date.now() };
  const tx = db.transaction(STORE, 'readwrite');
  tx.objectStore(STORE).put(full);
  await new Promise<void>((resolve) => (tx.oncomplete = () => resolve()));
}

// Runs on reconnect: replays queued writes and reconciles server-side conflicts
export async function reconcileOnReconnect(): Promise<void> {
  const db = await openOutboxDb();
  const entries: OutboxEntry[] = await new Promise((resolve) => {
    const req = db.transaction(STORE, 'readonly').objectStore(STORE).getAll();
    req.onsuccess = () => resolve(req.result as OutboxEntry[]);
  });

  for (const entry of entries.sort((a, b) => a.enqueuedAt - b.enqueuedAt)) {
    const res = await fetch(entry.url, {
      method: entry.method,
      headers: { 'Content-Type': 'application/json', 'If-Match': String(entry.baseVersion) },
      body: JSON.stringify(entry.body),
    });

    if (res.status === 409) {
      // Version mismatch — last-write-wins by server timestamp, or defer to
      // a version-vector merge if the payload supports field-level merging
      await resolveVersionConflict(entry, await res.json());
    }

    if (res.ok || res.status === 409) {
      const tx = db.transaction(STORE, 'readwrite');
      tx.objectStore(STORE).delete(entry.id);
    }
  }
}

async function resolveVersionConflict(entry: OutboxEntry, serverState: { version: number }): Promise<void> {
  // Simplest correct default: retry once against the server's current version
  await fetch(entry.url, {
    method: entry.method,
    headers: { 'Content-Type': 'application/json', 'If-Match': String(serverState.version) },
    body: JSON.stringify(entry.body),
  });
}

Last-write-wins is sufficient for single-owner records like a draft or a cart item; anything with concurrent multi-device edits — shared documents, collaborative lists — needs version vectors or CRDTs, because timestamp comparison alone cannot tell “the user’s other device changed this” apart from “this device is replaying a stale write.”

Background Sync and Request Replay Queues #

A queue that only replays while the tab is open is not durable — the subway commuter’s phone locks the screen, the browser suspends the tab, and the write never leaves the device. Background Sync and Request Replay Queues moves replay into the service worker itself, using the Background Sync API so the browser retries delivery even after the page that queued the request is gone.

/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;

const SYNC_TAG = 'replay-outbox';

// Called from the page after enqueueWrite() persists to IndexedDB
export async function requestBackgroundReplay(): Promise<void> {
  const registration = await navigator.serviceWorker.ready;

  if ('sync' in registration) {
    try {
      await (registration as ServiceWorkerRegistration & {
        sync: { register(tag: string): Promise<void> };
      }).sync.register(SYNC_TAG);
      return;
    } catch {
      // Registration can throw under permission or storage-pressure failures
    }
  }

  // Fallback for browsers without Background Sync (notably WebKit/Safari):
  // ask the page to replay directly on the 'online' event instead
  const clients = await self.clients.matchAll();
  clients.forEach((client) => client.postMessage({ type: 'REPLAY_FALLBACK_NEEDED' }));
}

// Inside the service worker script
self.addEventListener('sync', (event: SyncEvent) => {
  if (event.tag !== SYNC_TAG) return;
  event.waitUntil(replayQueuedRequests());
});

async function replayQueuedRequests(): Promise<void> {
  const entries = await readOutboxFromIndexedDb();

  for (const entry of entries) {
    try {
      const response = await fetch(entry.url, {
        method: entry.method,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(entry.body),
      });
      if (response.ok) await deleteOutboxEntry(entry.id);
      // Non-2xx and non-409 responses are left queued for the next sync event
    } catch {
      // Network failure mid-replay — throwing here re-triggers Background
      // Sync with browser-managed exponential backoff, so it is intentional
      throw new Error(`replay failed for ${entry.id}`);
    }
  }
}

The sync event handler is deliberately allowed to throw on network failure: the Background Sync spec re-schedules the event with its own backoff when the handler’s promise rejects, which is the one place in this page where letting an exception propagate is the correct behavior rather than a bug.

App Shell Precache and Offline Fallback Routing #

Losing connectivity mid-navigation should never produce the browser’s default “no internet” interstitial inside a PWA that claims to work offline — that experience signals to the user that the offline story was never real. App Shell Precache and Offline Fallback Routing precaches the shell during install and routes every failed navigation to a real, branded offline document instead.

/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;

const SHELL_CACHE = 'app-shell-v3';
const OFFLINE_URL = '/offline/';
const SHELL_ASSETS = ['/', '/offline/', '/app.css', '/app.js', '/manifest.webmanifest'];

self.addEventListener('install', (event: ExtendableEvent) => {
  event.waitUntil(
    caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS))
  );
});

self.addEventListener('activate', (event: ExtendableEvent) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((key) => key !== SHELL_CACHE).map((key) => caches.delete(key)))
    )
  );
});

self.addEventListener('fetch', (event: FetchEvent) => {
  const { request } = event;

  if (request.mode === 'navigate') {
    event.respondWith(handleNavigation(request));
    return;
  }

  if (request.url.endsWith('.css') || request.url.endsWith('.js')) {
    event.respondWith(staleWhileRevalidate(request));
  }
});

async function handleNavigation(request: Request): Promise<Response> {
  try {
    const network = await fetch(request);
    return network;
  } catch {
    // Offline, DNS failure, or timeout — always fall back to a cached page
    const cache = await caches.open(SHELL_CACHE);
    const cachedShell = await cache.match(request);
    return cachedShell ?? (await cache.match(OFFLINE_URL))!;
  }
}

async function staleWhileRevalidate(request: Request): Promise<Response> {
  const cache = await caches.open(SHELL_CACHE);
  const cached = await cache.match(request);

  const networkFetch = fetch(request).then((response) => {
    if (response.ok) cache.put(request, response.clone());
    return response;
  });

  // Serve stale immediately if available, refresh in the background regardless
  return cached ?? networkFetch;
}

The activate handler’s cache purge is what makes precache versioning safe — bumping SHELL_CACHE on every deploy guarantees stale assets are evicted the moment the new worker takes control, instead of accumulating indefinitely in Cache Storage.

Service Worker Error Telemetry and Debugging #

A thrown exception inside a fetch handler is invisible by default — there is no console attached to a locked phone screen, and the failure surfaces to the user only as a generic network error with no diagnostic trail back to an engineer. Service Worker Error Telemetry and Debugging treats the worker’s global scope as a telemetry source of its own, separate from and correlated with window-side error reporting.

/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;

interface SwErrorPayload {
  scope: 'service-worker';
  kind: 'uncaught' | 'unhandledrejection' | 'fetch-handler';
  message: string;
  stack?: string;
  url: string;
  timestamp: number;
  sessionId: string;
}

self.addEventListener('error', (event: ErrorEvent) => {
  reportSwError({
    scope: 'service-worker',
    kind: 'uncaught',
    message: event.message,
    stack: event.error?.stack,
    url: self.location.href,
    timestamp: Date.now(),
    sessionId: getOrCreateSessionId(),
  });
});

self.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
  const reason = event.reason instanceof Error ? event.reason : new Error(String(event.reason));
  reportSwError({
    scope: 'service-worker',
    kind: 'unhandledrejection',
    message: reason.message,
    stack: reason.stack,
    url: self.location.href,
    timestamp: Date.now(),
    sessionId: getOrCreateSessionId(),
  });
});

// Every fetch handler in this worker must route through this wrapper —
// respondWith rejecting is what produces the browser's blank network-error page
async function safeRespond(handler: () => Promise<Response>): Promise<Response> {
  try {
    return await handler();
  } catch (error) {
    reportSwError({
      scope: 'service-worker',
      kind: 'fetch-handler',
      message: error instanceof Error ? error.message : String(error),
      url: self.location.href,
      timestamp: Date.now(),
      sessionId: getOrCreateSessionId(),
    });
    return new Response('Offline — recovering', { status: 503, statusText: 'Service Worker Recovered' });
  }
}

function reportSwError(payload: SwErrorPayload): void {
  self.clients.matchAll().then((clients) => {
    clients.forEach((client) => client.postMessage({ type: 'SW_ERROR', payload }));
  });

  const body = JSON.stringify(payload);
  if ('sendBeacon' in self.navigator) {
    self.navigator.sendBeacon('/api/telemetry/sw', body);
  } else {
    fetch('/api/telemetry/sw', { method: 'POST', body, keepalive: true }).catch(() => {});
  }
}

function getOrCreateSessionId(): string {
  return self.registration.scope; // Placeholder — replace with a real propagated session ID
}

postMessage to every controlled client matters as much as the beacon call: it lets a foreground tab surface a visible “recovering” indicator instead of leaving the user staring at a silently retried request with no feedback.

State implications and cross-section interaction #

Service-worker durability does not stand apart from the rest of the client’s persistence story — it is the layer that keeps working when the tab that owns the other layers is gone. The IndexedDB outbox described above shares its underlying database engine, and often its exact schema conventions, with the dual-write pattern in LocalStorage and IndexedDB Sync Strategies — a write that fails to reach the server should land in the same durable store a crash-recovery snapshot would, so a single reconciliation pass on reconnect can drain both without duplicating logic.

The retry semantics inside the outbox reconciliation loop are deliberately the same shape as the exponential-backoff wrapper in Custom Hooks for Async Error Catching: both classify failures into a FailureKind, both cap retries, and both need an AbortSignal-aware cancellation path so a component unmount or a service-worker termination does not leave a retry loop running against a request the user no longer wants delivered.

Finally, replay and precache interact directly with reconnect-time cache warming. Draining the outbox before Cache Warming and Pre-fetching on Reconnect refreshes read caches avoids a narrow but real bug class: a stale read cache warmed from data that predates a queued write can briefly show the user a version of their own record that appears to have reverted, because the write that would supersede it is still sitting in the outbox. Sequencing matters — replay writes first, warm reads second.

Monitoring and observability #

Every one of the five failure domains above needs the same telemetry contract: a structured payload, a stable session identifier, and a delivery mechanism that survives the process that generated the error. Sampling is not optional at scale — a bad deploy that breaks the fetch handler for every user will otherwise generate one telemetry event per navigation per user, and the ingestion pipeline will fall over exactly when the signal matters most.

interface SwTelemetryEvent {
  id: string;
  domain: 'lifecycle' | 'sync' | 'replay' | 'precache' | 'telemetry-self';
  sessionId: string;
  traceId?: string; // Propagated from response headers to join server-side logs
  message: string;
  timestamp: number;
}

const SAMPLE_RATE = 0.1;

export function shouldSample(event: SwTelemetryEvent): boolean {
  // Always report the first occurrence of a distinct message per session,
  // then fall back to a flat sample rate to avoid drowning the pipeline
  return event.timestamp % 10 === 0 || Math.random() < SAMPLE_RATE;
}

Correlating a service-worker error with the window-side session that triggered it requires propagating the same sessionId and traceId into every postMessage and telemetry beacon — without that join key, an incident review sees two disconnected error streams instead of one causal chain from a user’s click through to the failed replay. In CI, fault-injection is what keeps this contract honest: a Playwright suite that forces page.route to abort requests, toggles the offline network condition, and asserts both that the offline document renders and that a SW_ERROR message reaches the client catches regressions that manual QA reliably misses, because manual testers rarely leave a tab open long enough to hit the waiting-state trap.

Frequently asked questions #

Why does a service worker update not take effect immediately? A new service worker enters the waiting state and stays there until every open tab controlled by the previous version is closed, unless the page explicitly calls skipWaiting. This prevents mixing script versions mid-session, but it also means a fix deployed to production can sit inactive for days on tabs users never close.

Is Background Sync enough to guarantee a queued request is delivered? No. Background Sync guarantees the browser will attempt the sync event when connectivity returns, but the handler can still fail, the browser can evict the registration under storage pressure, and Safari does not implement the API at all. Durable delivery requires an IndexedDB-backed queue with idempotent replay and a client-side fallback, not just a sync registration.

Can a service worker access the DOM or window object? No. A service worker executes in ServiceWorkerGlobalScope, a separate worker thread with no window, no document, and no synchronous access to the page. Communication with controlled pages happens exclusively through postMessage, the Clients API, and events like fetch and sync.

What happens if the fetch handler itself throws an exception? An uncaught exception inside a fetch event handler leaves the navigation or resource request unresolved, which the browser resolves as a generic network error shown to the user with no fallback UI. Every fetch handler must be wrapped so that any failure path still resolves respondWith with a Response, even if that response is a synthesized offline fallback.

How do I test offline recovery paths without shipping broken code? Use Chrome DevTools’ Application panel to force the offline network condition and the update-on-reload option, drive Workbox or custom service-worker logic through Playwright with route interception to simulate dropped requests, and run a CI job that asserts the offline fallback document is reachable and the outbox drains once connectivity is restored.

Should IndexedDB writes happen in the service worker or the page? Either context can open the same IndexedDB database, but the page is usually better positioned to enqueue writes since it owns the form submission, while the service worker is better positioned to replay them since it survives page navigation and closure. Splitting responsibilities this way avoids racing writes against a page unload.