PWA Offline Sync: The Outbox Pattern at the Fetch Boundary

One global fetch interceptor instead of wrapping 93 call sites — with retry taxonomy, exactly-once delivery, and why Background Sync API fails on iOS.

The short answer: To add offline write durability to a PWA, install a single global fetch interceptor that queues mutations into localStorage/IndexedDB when the network is unavailable, then replays them sequentially on reconnect. Use a page-side replayer only — not Background Sync API (iOS doesn't support it, and two replayers race). Stamp auth headers at replay time, not queue time. Inject a msg_id per operation so the server can deduplicate replays. This is how amux's dashboard (an iOS PWA managing AI coding agent fleets) made 93 mutation call sites offline-safe without touching a single one.

The problem: 93 call sites, silent loss

amux is a PWA — installable on iPhone and Android — that manages fleets of Claude Code, Codex, and Gemini CLI coding agents running on a developer's machine. The dashboard is the control plane: you send prompts, move board cards, steer sessions, and approve actions from your phone.

Before v0.9.179, the app had 93 raw fetch() mutation sites. They all assumed a live network. Offline — on an airplane, in a dead zone, when the home server was unreachable — every one of those commands threw an unhandled network error and the operation was silently lost. The user got nothing: no feedback, no queue, no retry. The board card didn't move. The prompt didn't send. The action was gone.

The naive fix is to wrap every call site with a queue check: 93 edits, 93 places to get the logic wrong, and every new endpoint is another opportunity to forget. The right fix is to intercept at the fetch boundary.

Layer 1: app shell caching

Offline write durability and offline load durability are two distinct problems. Solve them separately.

The service worker handles offline load: it uses a network-first strategy for / (the HTML shell) with a cache fallback, and cache-first for static assets (JS, CSS, images). When the network is down, the service worker serves the cached shell and the dashboard renders fully — zero bytes transferred. The amux e2e verifies this with the performance API: a shell offline-load must show transferSize === 0.

This is separate from the outbox. The service worker handles reads. The outbox handles writes.

Layer 2: the fetch interceptor (the centerpiece)

The outbox pattern is installed as a single global fetch interceptor that wraps window.fetch at app startup:

// Save original fetch before replacing it
const _origFetch = window.fetch.bind(window);

window.fetch = async function amuxFetchInterceptor(input, init = {}) {
  const url = typeof input === 'string' ? input : input.url;
  const method = (init.method || 'GET').toUpperCase();

  // Pass-through: GET requests and denylisted endpoints
  if (method === 'GET' || isDenylisted(url)) {
    return _origFetch(input, init);
  }

  // Online path: delegate untouched — zero connected impact
  if (navigator.onLine) {
    try {
      return await _origFetch(input, init);
    } catch (err) {
      // Network threw even though navigator.onLine was true (flaky connection)
      // Fall through to queue
    }
  }

  // Offline path: queue the mutation, return synthetic 202
  const op = {
    id: crypto.randomUUID(),        // msg_id for exactly-once dedup
    url,
    method,
    body: init.body ?? null,
    headers: { ...init.headers },   // NOTE: auth headers NOT stamped here
    queuedAt: Date.now(),
  };
  enqueueOp(op);

  // Caller gets a real Response — no special error handling required
  return new Response(JSON.stringify({ queued: true, id: op.id }), {
    status: 202,
    headers: { 'Content-Type': 'application/json' },
  });
};

The critical design decisions:

The mutation denylist

Not all mutations should be deferred. Some operations are interactive (they require a live connection to be meaningful) or ephemeral (deferring them would be actively wrong):

const DENYLIST = [
  '/api/terminal/',    // terminal keystrokes — stale when replayed
  '/api/upload/',      // file uploads — body may be a stream, not re-readable
  '/api/auth/',        // login — cannot dequeue before auth exists
  '/api/tts/',         // text-to-speech — ephemeral audio request
  '/api/telemetry/',   // analytics pings — not worth replaying
  '/api/speedtest/',   // connectivity test — meaningless offline
];

function isDenylisted(url) {
  return DENYLIST.some(prefix => url.includes(prefix));
}

Everything not on the denylist — board mutations, session sends, steer commands, CRM updates, notes, scheduler changes — is eligible for queueing.

Exactly-once delivery with msg_id

Consider the failure mode: the user sends a prompt while briefly offline. The outbox queues it. Connectivity returns. The replayer sends the request. The server processes it and returns 200 — but the response is lost in transit (the connection drops in the 50ms between server response and client receipt). The replayer sees a network error and retries. Without deduplication, the prompt is delivered twice.

The fix: inject a msg_id (a client-generated UUID) into every queued mutation. For JSON bodies:

function injectMsgId(op) {
  try {
    const body = JSON.parse(op.body);
    body.msg_id = op.id;
    op.body = JSON.stringify(body);
  } catch {
    // Non-JSON body — attach as header instead
    op.headers['X-Amux-Msg-Id'] = op.id;
  }
  return op;
}

The server maintains a dedup store (a short-TTL set keyed by msg_id). On replay, if msg_id is already present, the server returns the original response without re-executing side effects. This makes replays exactly-once: the operation lands once regardless of how many times the replayer tries.

Auth headers at replay time, not queue time. The queued operation stores the URL, method, and body — but not the auth headers. Auth tokens expire (typically 1–24 hours). An operation queued while the user was online may replay hours later when those tokens have rotated. If you stamp the token at queue time, the replay silently 401-fails and the operation is lost. Instead, the replayer reads the current auth token from memory at replay time and injects it into the outgoing request.

The single replayer

The replayer flushes the outbox sequentially when connectivity returns:

async function runOutboxReplay() {
  const ops = loadOutbox();  // reads from localStorage + IDB
  if (!ops.length) return;

  for (const op of ops) {
    if (!navigator.onLine) break;  // abort if we go offline mid-replay

    const opWithAuth = injectMsgId(injectAuthHeaders(op));
    try {
      const res = await _origFetch(opWithAuth.url, {
        method: opWithAuth.method,
        body: opWithAuth.body,
        headers: opWithAuth.headers,
      });

      if (isTransient(res.status)) {
        // 5xx, 401, 408, 429 — keep in outbox for next sync
        continue;
      }
      if (isPermanent(res.status)) {
        // 400, 403, 404, 422 — surface once, drop from queue
        surfaceError(op, res.status);
        removeFromOutbox(op.id);
        continue;
      }
      // Success
      removeFromOutbox(op.id);
    } catch {
      // Network error mid-replay — stop, try again next time
      break;
    }
  }
}

// Wire to connectivity events
window.addEventListener('online', runOutboxReplay);
document.addEventListener('visibilitychange', () => {
  if (!document.hidden) runOutboxReplay();
});
// Also run at app startup (catches ops queued in a previous session)
runOutboxReplay();

Sequential replay (not parallel) is deliberate: it preserves operation order (important when ops depend on each other, e.g. create then update) and prevents a burst of concurrent requests from overwhelming the server after a long offline period.

Why not Background Sync API?

The Background Sync API (navigator.serviceWorker.ready.then(sw => sw.sync.register('outbox-replay'))) sounds like the right tool. It's specifically designed for this. Two problems stopped us:

1. iOS doesn't support it. The amux dashboard is used heavily from iPhone — it's the whole phone-first pitch. Background Sync is not implemented in Safari/WKWebView as of mid-2026. Any replay strategy that relies on it degrades silently to "never replays" on iOS. That's worse than not having offline support at all, because the user thinks their command went through.

2. Two replayers race. amux's e2e caught this during development. At reconnect, both the page-side online handler and the service worker's Background Sync event fired. The service worker read from a potentially stale IndexedDB snapshot — it could resurrect operations the page had already settled and remove from the outbox. The service worker's SYNC_COMPLETE handler then merged those "settled" ops back into localStorage, re-queuing them. The result: duplicate delivery of already-completed operations.

The fix is having exactly one replayer. Page-side only. The service worker retains its app-shell caching role but does not touch the outbox.

Retry taxonomy

StatusClassificationAction
5xxTransient — server errorKeep in outbox, retry next sync
401Transient — auth expiryRefresh token, retry next sync
408Transient — timeoutKeep in outbox, retry next sync
429Transient — rate limitedKeep in outbox, retry next sync (with backoff)
400Permanent — bad requestSurface error once, drop from queue
403Permanent — forbiddenSurface error once, drop from queue
404Permanent — not foundSurface error once, drop from queue
422Permanent — validation errorSurface error once, drop from queue
Network throwTransient — connectivityStop replay, retry at next online event

Outbox limits. Unbounded queues become a denial-of-service vector against your own server at reconnect. amux caps the outbox at 200 operations and enforces a 7-day TTL — operations older than 7 days are dropped on the assumption that the user's intent has changed. Before each replay, a reconciliation pass removes superseded operations (e.g. two PATCH calls to the same resource can often be collapsed to the latest one).

localStorage vs IndexedDB: use both

The outbox is written to both localStorage and IndexedDB. This is intentional:

The replayer always reads from localStorage as the primary source. IDB is the recovery path. They stay in sync: every write to localStorage gets a corresponding IDB write (async, best-effort). Every delete from localStorage gets a corresponding IDB delete.

Testing with a real network cut

Testing offline behavior in a unit test gives false confidence — you're mocking navigator.onLine, not a real network failure. amux uses a Playwright e2e test that cuts the network at the CDP (Chrome DevTools Protocol) level:

test('outbox queues offline and replays on reconnect', async ({ page, context }) => {
  // 1. Load the app
  await page.goto('/');
  await page.waitForSelector('[data-testid="board"]');

  // 2. Cut network at CDP level — real offline, not a mock
  await context.setOffline(true);

  // 3. Try a mutation that would normally go to the server
  await page.click('[data-testid="board-card-move"]');

  // 4. Verify: synthetic 202 received, op in outbox, card visually moved
  const outbox = await page.evaluate(() => JSON.parse(localStorage.getItem('amux_outbox') || '[]'));
  expect(outbox).toHaveLength(1);
  expect(outbox[0].url).toContain('/api/board/');

  // 5. Reload while offline — app shell must load from cache
  await page.reload();
  const transferSize = await page.evaluate(() =>
    performance.getEntriesByType('navigation')[0].transferSize
  );
  expect(transferSize).toBe(0);  // zero bytes from network

  // 6. Outbox survives reload
  const outboxAfterReload = await page.evaluate(() => JSON.parse(localStorage.getItem('amux_outbox') || '[]'));
  expect(outboxAfterReload).toHaveLength(1);

  // 7. Reconnect
  await context.setOffline(false);
  await page.waitForTimeout(500);  // replayer fires on online event

  // 8. Verify: op applied server-side, outbox empty
  const outboxAfterSync = await page.evaluate(() => JSON.parse(localStorage.getItem('amux_outbox') || '[]'));
  expect(outboxAfterSync).toHaveLength(0);

  // 9. Verify server state matches client state
  const serverState = await page.evaluate(() =>
    fetch('/api/board').then(r => r.json())
  );
  expect(serverState.items.find(i => i.id === movedCard.id).status).toBe('done');
});

11 assertions total. The test catches the exact failure modes that bit the Background Sync approach: outbox survival across reload, zero-bytes shell load, and server-side confirmation after replay.

In production: amux v0.9.179

The outbox pattern shipped in amux v0.9.179 (commits b13fea4 and 611a145, July 2026). Results:

amux is an open-source control plane for running an AI engineering team — managing parallel Claude Code, Codex, and Gemini CLI sessions with a web dashboard and iOS app. The PWA needs to work reliably from wherever the developer is, including in environments where the home server is temporarily unreachable. Offline sync isn't a nice-to-have; it's a requirement.

amux — AI engineering team dashboard, offline-first PWA (311★ GitHub)
Run dozens of Claude Code, Codex, and Gemini CLI agents from your phone. The outbox pattern described here is live in the amux iOS PWA — open source, single Python file, MIT licensed. The full implementation is in amux-server.py.
View on GitHub ★

FAQ

What is the outbox pattern in a PWA?

The outbox pattern captures write operations (POST, PATCH, DELETE) into a local queue (the outbox) when the network is unavailable, then replays them sequentially once connectivity returns. In a PWA the outbox lives in localStorage or IndexedDB. The pattern guarantees no mutation is silently lost when the user goes offline.

Why not use the Background Sync API for PWA offline sync?

Two reasons. First, iOS Safari does not support Background Sync as of mid-2026 — any PWA targeting iPhone users cannot rely on it. Second, if you also have a page-side flush (on the online event), you get two replayers racing: the service worker replays from a potentially stale IndexedDB snapshot and can resurrect operations the page already settled. One replayer — page-side only — avoids the race and works on iOS and Android alike.

How do you make offline writes idempotent?

Inject a client-generated UUID (msg_id) into every queued mutation. The server deduplicates on msg_id — a duplicate replay returns the original response without re-executing side effects. This guarantees exactly-once delivery even when the network drops after the server processes the request but before the client receives the response.

Should auth headers be stamped at queue time or replay time?

Always at replay time. Auth tokens expire. Stamping them at queue time means a replay hours later silently 401-fails and the operation is lost. At replay time, read the current token from memory and inject it into the outgoing request.

How do you handle different error codes on outbox replay?

Transient errors (5xx, 401, 408, 429) requeue for the next sync cycle. Permanent client errors (400, 403, 404, 422) surface once and are dropped — retrying will never succeed. Network throws abort the replay loop; the replayer tries again at the next online event. Cap the outbox at a fixed size (200 ops) and TTL (7 days) to prevent unbounded growth.

Try amux — offline-first AI agent dashboard

Open-source control plane for AI agent teams with offline sync, self-healing watchdog, and phone-first management. Single Python file, MIT licensed.

brew install mixpeek/amux/amux
amux register myproject --dir ~/Dev/myproject --yolo
amux serve  # → https://localhost:8822
View on GitHub Getting Started

Related guides