PAUL CHONGSenior Software Engineer

Offline & Network Resilience

2026-08-08 · 8 min read

A resilient app doesn't fail when the network does — it degrades gracefully. Reads serve cached data. Writes queue locally and flush when connectivity returns. The user experience is continuous even when the underlying connection isn't.

Reading offline

Service Worker and Cache API

A Service Worker is a JavaScript file that runs in its own thread, separate from the page. It acts as a programmable proxy — every network request the page makes passes through it first, and it decides whether to serve a cached response or let the request go to the network.

Two globals are available inside a Service Worker file:

  • self — the Service Worker's own global scope, equivalent to window in a regular page. You use it to register event listeners on the SW itself (self.addEventListener).
  • caches — the browser's Cache API, a persistent key-value store for request/response pairs. Unlike the browser's automatic HTTP cache, this one is entirely under your control — you decide what gets stored, when it's served, and when it's evicted.

On first load, the SW caches the app shell and critical assets. On subsequent visits — including offline ones — it serves those resources from cache before making any network request.

// sw.js

const SHELL_CACHE = 'shell-v1';
const SHELL_ASSETS = ['/', '/index.html', '/main.css', '/main.js'];

// Cache the app shell on install
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS))
  );
});

// Serve shell assets from cache first, fall back to network
self.addEventListener('fetch', (event) => {
  const isShellAsset = SHELL_ASSETS.includes(new URL(event.request.url).pathname);

  if (isShellAsset) {
    event.respondWith(
      caches.match(event.request).then((cached) => cached ?? fetch(event.request))
    );
  }
});

For feed data, cache the last response under its request URL. On the next visit, serve the cached response immediately while revalidating in the background — stale-while-revalidate at the Service Worker level:

const DATA_CACHE = 'data-v1';

self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/api/feed')) {
    event.respondWith(
      caches.open(DATA_CACHE).then(async (cache) => {
        const cached = await cache.match(event.request);

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

        return cached ?? networkFetch; // serve cache instantly, update in background
      })
    );
  }
});

See Client-Side Caching for the HTTP-level stale-while-revalidate header, which achieves the same pattern without a Service Worker.

IndexedDB for client store persistence

The in-memory store is lost on page close. IndexedDB persists it to disk so the next session can hydrate from local data before any network response arrives — the app feels instant even on a slow connection.

// On store update: persist to IndexedDB
async function persistStore(state) {
  const db = await openDB('app-store', 1);
  await db.put('store', state, 'root');
}

// On app init: rehydrate from IndexedDB before first render
async function hydrateStore() {
  const db = await openDB('app-store', 1);
  const savedState = await db.get('store', 'root');
  if (savedState) store.dispatch(rehydrate(savedState));
}

The sequence on load:

1. App starts → rehydrate from IndexedDB (instant, no network)
2. Render with cached state → user sees content immediately
3. Network request completes → merge fresh data into store
4. UI updates with any new content

This is stale-while-revalidate applied at the store level — serve stale local data immediately, update when the network responds.

Writing offline — the outbox pattern

Reads failing offline is recoverable. Writes failing silently is not — the user thinks their post was sent. The outbox pattern queues mutations locally and flushes them when connectivity returns.

1. User submits a post
2. Client writes the mutation to IndexedDB outbox, keyed by idempotency key
3. Apply optimistic update → show post in pending state
4. Attempt network request
5a. Success → remove from outbox, reconcile store with server response
5b. Network failure → mutation stays in outbox, retry timer starts
6. On reconnect → flush outbox in order, same idempotency keys

The outbox is backed by IndexedDB so it survives page refreshes and tab closes — a localStorage array would be lost if the browser crashed mid-flight.

// outbox.js — IndexedDB-backed outbox
import { openDB } from 'idb'; // thin IndexedDB wrapper

const db = openDB('app', 1, {
  upgrade(db) {
    db.createObjectStore('outbox', { keyPath: 'id' });
  },
});

export const outbox = {
  add:    async (mutation) => (await db).add('outbox', mutation),
  getAll: async ()         => (await db).getAll('outbox'),
  remove: async (id)       => (await db).delete('outbox', id),
};
async function submitPost({ body, mediaIds }) {
  const mutation = {
    id: crypto.randomUUID(),        // idempotency key
    type: 'CREATE_POST',
    payload: { body, mediaIds },
    status: 'pending',
    createdAt: Date.now(),
  };

  // 1. Write to outbox before touching the network
  await outbox.add(mutation);

  // 2. Optimistic update
  dispatch(addPost({ ...mutation.payload, id: mutation.id, pending: true }));

  // 3. Attempt send
  await flushOutbox();
}

async function flushOutbox() {
  const pending = await outbox.getAll();

  for (const mutation of pending) {
    try {
      const response = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Idempotency-Key': mutation.id },
        body: JSON.stringify(mutation.payload),
      });
      // Success: replace optimistic entry with server response
      dispatch(confirmPost({ tempId: mutation.id, post: await response.json() }));
      await outbox.remove(mutation.id);
    } catch {
      // Network failure: leave in outbox, retry later
    }
  }
}

// Flush when connectivity returns
window.addEventListener('online', flushOutbox);

Mutations flush in creation order to preserve causality — a reply must land after the post it replies to.

Background Sync API

The Background Sync API lets a Service Worker flush the outbox after the tab is closed — the browser wakes the SW when connectivity returns and runs the sync:

// Register a sync from the page
async function submitPost(mutation) {
  await outbox.add(mutation);
  await navigator.serviceWorker.ready;
  await registration.sync.register('flush-outbox');
}

// SW handles the sync event
self.addEventListener('sync', (event) => {
  if (event.tag === 'flush-outbox') {
    event.waitUntil(flushOutbox());
  }
});

Browser support is uneven — Safari does not support Background Sync. Treat it as a best-effort enhancement: keep the in-tab window.addEventListener('online', flushOutbox) as the primary path, and layer Background Sync on top for browsers that support it.

Retry strategy

Not all failures are worth retrying. The first decision is whether to retry at all.

Never retry:

  • 400 Bad Request — malformed input, retrying sends the same bad data
  • 401 Unauthorized — credentials are invalid, retry won't help
  • 403 Forbidden — permissions issue, not transient
  • 404 Not Found — the resource doesn't exist
  • 422 Unprocessable Entity — server understood the request but rejected it for business logic reasons

Retry:

  • Network timeout / connection failure — transient
  • 429 Too Many Requests — respect the Retry-After header if present
  • 500, 502, 503, 504 — server-side errors, likely transient

Exponential backoff with jitter

Retrying immediately on failure just hammers a struggling server. Exponential backoff increases the delay between retries. Jitter adds randomness to prevent every client from retrying simultaneously — the thundering herd problem.

async function fetchWithRetry(url, options, { maxRetries = 4 } = {}) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);

      // Non-retryable
      if ([400, 401, 403, 404, 422].includes(response.status)) return response;

      // Retryable server error
      if (!response.ok && attempt < maxRetries) throw new Error(response.status);

      return response;
    } catch (err) {
      if (attempt === maxRetries) throw err;

      const base = Math.min(1000 * 2 ** attempt, 30000); // cap at 30s
      const jitter = base * 0.2 * (Math.random() * 2 - 1); // ±20%
      await sleep(base + jitter);
    }
  }
}

After maxRetries failures, surface a persistent error to the user rather than retrying forever. A mutation that has failed 4 times is unlikely to succeed on the 5th — the user should know and decide whether to retry manually.

Stale feed detection

When a user returns to a tab they left open, the feed data may be hours old. Silently prepending new posts would cause scroll disruption — content jumping while they're reading. Instead, detect staleness and let the user choose when to refresh.

// Track when the feed was last fetched
const feedMeta = {
  lastFetchedAt: Date.now(),
  newerCursor: null,
};

async function checkForNewPosts() {
  const { newerCursor, count } = await fetch(`/api/feed/check?since=${feedMeta.lastFetchedAt}`).then(r => r.json());

  if (count > 0) {
    feedMeta.newerCursor = newerCursor;
    showBanner(`${count} new posts available`);
  }
}

// User taps the banner
function loadNewPosts() {
  fetchFeed({ cursor: feedMeta.newerCursor, prepend: true });
  hideBanner();
  scrollToTop();
}

Trigger checkForNewPosts on tab focus (visibilitychange) after the tab has been inactive for more than a threshold (e.g. 5 minutes).

Cross-tab coordination

Multiple open tabs create coordination problems: the same WebSocket connection shouldn't be opened by every tab, and a mutation in one tab should update the others.

BroadcastChannel for store sync across tabs:

const channel = new BroadcastChannel('store-sync');

// Broadcast mutations to other tabs
function dispatch(action) {
  store.dispatch(action);
  channel.postMessage(action);
}

// Receive mutations from other tabs
channel.onmessage = ({ data: action }) => {
  store.dispatch(action); // apply without re-broadcasting
};

Web Locks API to elect one tab as the WebSocket or polling leader — only one tab holds the connection, avoiding N duplicate connections:

navigator.locks.request('feed-leader', async (lock) => {
  // This tab holds the lock — only one tab can at a time
  // If this tab closes, the lock releases and another tab acquires it
  await startWebSocketConnection();
  await holdUntilTabCloses(); // keep the lock alive
});

When the leader tab closes, the browser releases the lock and the next waiting tab acquires it and starts its own connection. No explicit leader election protocol needed — the Lock API handles it.