Skip to content

Offline UX and fallbacks

Offline UX is everything a user sees and can do when the network is missing, slow, or lying: fallback pages and images, connectivity indicators, queued actions, stale-data labels, and the recovery once the connection returns. A service worker makes offline possible. Offline UX makes it understandable. Without it, a cached app either fails with the browser's error page or silently shows old data as if it were current. This page covers the full stack: fallback routing in the service worker, connectivity detection that doesn't lie, UI patterns with complete components, offline forms, offline search, sessions that expire while offline, lie-fi timeouts, and how to test all of it.

Key takeaways

  • Design for five states, not two: online, offline, lie-fi (connected but useless), captive portal, and "server down". navigator.onLine can only tell you about the first two, and it's only trustworthy when it says false.
  • Every navigation needs a fallback. Serve network-first HTML with a timeout, then the cached copy, then a precached, self-contained offline page. Images get a precached SVG placeholder, and API calls get a machine-readable 503.
  • Confirm connectivity with a heartbeat that the service worker must not answer from cache, and feed real request outcomes into the same monitor so you rarely need to poll.
  • Tell users what's happening and what will happen. Use a persistent role="status" banner, per-item "waiting to send" states, and "updated 3 hours ago" labels on cached data. Prefer queuing an action over disabling it.
  • Queue writes in IndexedDB with an idempotency key. Replay them with Background Sync where available (Chromium), and with online, visibilitychange and startup triggers everywhere else.
  • A network error is not a logout. Keep offline reads working when a token expires, pause the outbox on 401, and clear per-user data on sign-out.
  • Test with DevTools offline mode and with real disconnection. Emulated offline and throttling don't reproduce captive portals, packet loss, DNS failures or flapping radios, so run the critical paths on real devices and OS-level link conditioners too.

The connectivity states you actually design for

"Online or offline" is a false binary. Your users spend real time in the states between, and each needs different UI:

State What's happening navigator.onLine What fetch() does Detect with UI response
Online Requests succeed in normal time true Resolves quickly Successful requests Nothing; don't show "online" chrome
Offline No network interface, airplane mode, Wi-Fi off false Rejects immediately with TypeError offline event, onLine === false Offline banner, cached content, queued actions
Lie-fi Connected, but packets are lost or delayed (weak cellular, congested Wi-Fi, elevator) true Hangs for seconds to minutes, then may succeed or fail Timeouts, slow heartbeat "Slow connection" notice, serve cache after a timeout
Captive portal Wi-Fi requires a sign-in page; every request is redirected true Resolves with the portal's HTML (or a CORS failure) Heartbeat returns the wrong status or body Treat as offline, and suggest signing in to the network
Server down / blocked Your origin or API is unreachable, other sites work true 5xx, timeout, or TypeError Heartbeat to your own origin fails "Service unavailable" (not "you're offline")
Signed out Network fine, session expired true 401/403 Status codes Re-authentication prompt; keep offline data readable

Messaging matters here. Telling someone on a working hotel Wi-Fi that they are offline, when your API is actually down, sends them to their router settings for nothing. Keep "you're offline" for confirmed loss of connectivity. When only your server is failing, say so.

Offline fallback pages

What happens without a fallback

When a navigation request fails and your service worker has nothing to return, the browser shows its own network error page: Chrome's dinosaur page, or the equivalent error screens in Safari and Firefox. For an installed PWA running in a standalone window, that's especially jarring. The app suddenly looks like a broken website, and on platforms without browser UI there's often no reload button.

The same happens if your handler explicitly returns Response.error(), or if the promise passed to respondWith() rejects. The browser treats both as a network error.

Designing the offline page

A good offline page is a small, precached, self-contained document:

  • Self-contained. Inline the CSS, inline any SVG, and avoid web fonts unless they are precached. Every external resource is another request that fails offline.
  • Root-absolute URLs. The offline page is served in response to the requested URL (/articles/42). The address bar keeps that URL, so relative URLs in the offline page resolve against /articles/. Use /assets/..., never assets/....
  • Retry built in. Because the URL is preserved, location.reload() retries the original request. Add a visible Retry button (standalone windows may have no reload button) and retry automatically on the online event.
  • Useful, not apologetic. List what the user can do offline: pages already visited, content saved for offline, the app's home screen. An offline page that links to cached content turns a dead end into navigation.
  • Brand-consistent, but light. Match the app's colors and layout so it doesn't look like an error from a different product. Keep it under roughly 10–20 KB.
  • Honest status. Don't claim the user is offline if you can't know that. "We couldn't reach the server" covers offline, lie-fi and outages.

This complete offline page lists cached pages from the runtime pages cache and items from the "save for offline" index, with no external dependencies:

offline.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <meta name="color-scheme" content="light dark">
  <title>Offline · Example App</title>
  <style>
    :root { --bg: #fff; --fg: #1b1b1f; --muted: #5f6368; --accent: #5a0fc8; }
    @media (prefers-color-scheme: dark) { :root { --bg: #121212; --fg: #e8e8ea; --muted: #a0a4ab; --accent: #b388ff; } }
    body { margin: 0; font: 16px/1.5 system-ui, sans-serif; background: var(--bg); color: var(--fg);
           padding: max(24px, env(safe-area-inset-top)) 24px 24px; }
    main { max-width: 36rem; margin: 10vh auto 0; }
    h1 { font-size: 1.5rem; margin: 0 0 .5rem; }
    p { color: var(--muted); }
    button { font: inherit; padding: .6rem 1.2rem; border-radius: .5rem; border: 0;
             background: var(--accent); color: #fff; cursor: pointer; }
    button:focus-visible { outline: 3px solid var(--fg); outline-offset: 2px; }
    ul { padding-left: 1.2rem; } a { color: var(--accent); }
    [hidden] { display: none; }
  </style>
</head>
<body>
  <main>
    <svg aria-hidden="true" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor"
         stroke-width="2" stroke-linecap="round"><path d="M1 1l22 22M16.7 11.1A11 11 0 0 1 19 12.5M5 12.5a11 11 0 0 1 5.2-2.4M10.7 5.1A16 16 0 0 1 22.6 9M1.4 9a16 16 0 0 1 4.4-2.8M8.5 16.1a6 6 0 0 1 7 0M12 20h.01"/></svg>
    <h1>We couldn't reach the server</h1>
    <p id="status" role="status">You may be offline, or your connection may be too weak right now.
       This page will reload automatically when the connection comes back.</p>
    <button type="button" id="retry">Try again</button>

    <section id="available" hidden aria-labelledby="available-heading">
      <h2 id="available-heading">Available offline</h2>
      <ul id="list"></ul>
    </section>
  </main>

  <script>
    "use strict";
    const retry = () => location.reload();
    document.getElementById("retry").addEventListener("click", retry);
    addEventListener("online", retry);

    // List pages this device already has: saved-for-offline items first, then visited pages.
    (async () => {
      if (!("caches" in self)) return;
      const items = new Map();
      try {
        // caches.has() first: caches.open() would create empty caches as a side effect.
        if (await caches.has("app-saved-v1")) {
          const saved = await caches.open("app-saved-v1");
          const index = await saved.match("/__saved__/index.json");
          if (index) {
            for (const [url, item] of Object.entries(await index.json())) items.set(url, item.title);
          }
        }
        if (await caches.has("app-pages-v1")) {
          const pages = await caches.open("app-pages-v1");
          for (const request of await pages.keys()) {
            if (!items.has(request.url)) items.set(request.url, new URL(request.url).pathname);
          }
        }
      } catch {
        return; // storage unavailable: the page still works without the list
      }
      const here = location.href;
      const list = document.getElementById("list");
      for (const [url, label] of [...items].slice(0, 30)) {
        if (url === here) continue;
        const li = document.createElement("li");
        const a = document.createElement("a");
        a.href = url;
        a.textContent = label; // textContent: titles are data, never HTML
        li.append(a);
        list.append(li);
      }
      document.getElementById("available").hidden = list.children.length === 0;
    })();
  </script>
</body>
</html>

If your Content Security Policy forbids inline scripts and styles, move them to precached files, or allow them by hash. The offline page is usually static, so hashes are stable between builds.

Routing navigations to the fallback

The fallback belongs at the end of a network-first chain for navigations: fresh from the network when possible, a cached copy of the same page when not, and the offline page as the last resort. The worker below implements that chain with a timeout (for lie-fi), navigation preload, freshness stamping for API data, a fallback image, and a machine-readable offline response for API calls:

sw.js (fallback routing)
"use strict";

const VERSION = "v7"; // bump when the fallback assets change
const FALLBACK_CACHE = `app-fallbacks-${VERSION}`;
const PAGES_CACHE = "app-pages-v1";
const API_CACHE = "app-api-v1";
const IMAGES_CACHE = "app-images-v1";

const OFFLINE_URL = "/offline.html";
const FALLBACK_IMAGE_URL = "/img/offline-placeholder.svg";
const HEALTH_PATH = "/__health";

const NAVIGATION_TIMEOUT_MS = 3500;
const API_TIMEOUT_MS = 5000;
const MAX_PAGES = 50;
const MAX_IMAGES = 120;

self.addEventListener("install", (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(FALLBACK_CACHE);
      // "reload": never capture a stale copy of the fallback from the HTTP cache.
      await cache.addAll([OFFLINE_URL, FALLBACK_IMAGE_URL].map((url) => new Request(url, { cache: "reload" })));
    })()
  );
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      if (self.registration.navigationPreload) {
        await self.registration.navigationPreload.enable();
      }
      const names = await caches.keys();
      await Promise.all(
        names
          .filter((name) => name.startsWith("app-fallbacks-") && name !== FALLBACK_CACHE)
          .map((name) => caches.delete(name))
      );
    })()
  );
});

self.addEventListener("fetch", (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // Connectivity probes must reach the network: never answer them from a cache.
  if (url.pathname === HEALTH_PATH) return;
  // Writes are handled by the page's outbox, not replayed here.
  if (request.method !== "GET") return;

  if (request.mode === "navigate") {
    event.respondWith(handleNavigation(event));
  } else if (url.origin === self.location.origin && url.pathname.startsWith("/api/")) {
    event.respondWith(handleApi(event));
  } else if (request.destination === "image") {
    event.respondWith(handleImage(event));
  }
});

// ---- Navigations: network (with preload) -> cached page -> offline page ----

async function handleNavigation(event) {
  const network = (async () => (await event.preloadResponse) ?? fetch(event.request))();
  try {
    return await networkFirst(event, network, PAGES_CACHE, NAVIGATION_TIMEOUT_MS, MAX_PAGES);
  } catch {
    const fallbacks = await caches.open(FALLBACK_CACHE);
    return (await fallbacks.match(OFFLINE_URL)) ?? Response.error();
  }
}

// ---- API reads: network -> cached copy (marked stale) -> synthetic 503 ------

async function handleApi(event) {
  try {
    return await networkFirst(event, fetch(event.request), API_CACHE, API_TIMEOUT_MS);
  } catch {
    return new Response(JSON.stringify({ error: "offline", message: "No network and no cached copy." }), {
      status: 503,
      statusText: "Service Unavailable",
      headers: { "Content-Type": "application/json", "SW-Cache-Status": "offline-miss" },
    });
  }
}

// ---- Images: cache -> network -> placeholder -------------------------------

async function handleImage(event) {
  const cache = await caches.open(IMAGES_CACHE);
  const cached = await cache.match(event.request);
  if (cached) return cached;
  try {
    const response = await fetch(event.request);
    // Opaque cross-origin images (status 0) are cacheable, but they cost quota padding.
    if (response.ok || response.type === "opaque") {
      event.waitUntil(cache.put(event.request, response.clone()).then(() => trim(IMAGES_CACHE, MAX_IMAGES)));
    }
    return response;
  } catch {
    const fallbacks = await caches.open(FALLBACK_CACHE);
    return (await fallbacks.match(FALLBACK_IMAGE_URL)) ?? Response.error();
  }
}

// ---- Shared: network-first with a timeout that still refreshes the cache ----

/**
 * Race the network against a timer. If the timer wins and a cached copy exists,
 * answer from cache but let the network request finish and update the cache.
 * Rejects only when there is neither a network response nor a cached copy.
 */
async function networkFirst(event, networkPromise, cacheName, timeoutMs, maxEntries) {
  const { request } = event;
  const network = networkPromise.then(async (response) => {
    if (response.ok && response.type === "basic") {
      const cache = await caches.open(cacheName);
      await cache.put(request, await stamp(response.clone()));
      if (maxEntries) await trim(cacheName, maxEntries);
    }
    return response;
  });
  // Keep the worker alive until the network attempt and cache write settle.
  event.waitUntil(network.catch(() => undefined));

  let timer;
  const timeout = new Promise((resolve) => {
    timer = setTimeout(resolve, timeoutMs, "timeout");
  });
  try {
    const winner = await Promise.race([network, timeout]);
    if (winner !== "timeout") return winner;
    const cached = await caches.match(request, { cacheName });
    if (cached) return markStale(cached, "stale-timeout");
    return await network; // nothing cached: keep waiting for the slow network
  } catch {
    const cached = await caches.match(request, { cacheName });
    if (cached) return markStale(cached, "stale-offline");
    throw new Error("network and cache both missed");
  } finally {
    clearTimeout(timer);
  }
}

/** Record when the response was fetched, for "last updated" labels in the UI. */
async function stamp(response) {
  const headers = new Headers(response.headers);
  headers.set("SW-Fetched-At", new Date().toISOString());
  return new Response(await response.blob(), {
    status: response.status,
    statusText: response.statusText,
    headers,
  });
}

/** Tell the page this response came from the cache, and why. */
function markStale(response, status) {
  const headers = new Headers(response.headers);
  headers.set("SW-Cache-Status", status);
  return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
}

/** Simple FIFO cap: Cache.keys() returns entries in insertion order. */
async function trim(cacheName, maxEntries) {
  const cache = await caches.open(cacheName);
  const keys = await cache.keys();
  await Promise.all(keys.slice(0, Math.max(0, keys.length - maxEntries)).map((key) => cache.delete(key)));
}

A few details in this worker are worth calling out:

  • The network request outlives the timeout. When the timer wins, the user gets the cached copy immediately. But the network promise keeps running, extended by event.waitUntil(), so a slow-but-successful response still refreshes the cache for next time. Without the waitUntil(), the browser may terminate the worker as soon as respondWith() settles, and the refresh is lost.
  • waitUntil() is called synchronously. event.waitUntil() throws InvalidStateError once the event is no longer active. Calling it before the first await sidesteps any doubt.
  • Only basic (same-origin) ok responses are cached. Caching a 500 would make an outage permanent for that URL. Opaque responses aren't cached for pages or API calls.
  • The offline page isn't marked with a status code. The offline response is the cached 200 document, and the browser renders it for the requested URL. If your analytics need to distinguish it, have the offline page report itself, or wrap it in a Response with status 503: browsers render the body of a 503 navigation response normally.
  • Cache.keys() order is insertion order. The spec's request-response list is ordered, and cache.put() for an existing key removes the old entry and appends the new one. That makes trim() a FIFO cap. For true LRU behavior you need access timestamps, which Workbox's ExpirationPlugin keeps in IndexedDB.

Fallback images, fonts and API responses

A fallback must match what the requester can use. request.destination identifies the requester for element-initiated loads:

request.destination Typical requester Good fallback Notes
"document" Top-level navigation Cached page → offline page request.mode is "navigate"
"iframe" Embedded document Tiny "content unavailable" document, or nothing Also mode: "navigate"; don't serve the full offline page into a 200 px iframe
"image" <img>, CSS background-image, <picture> Precached SVG placeholder Use object-fit in CSS so one placeholder fits any aspect ratio
"font" @font-face Usually nothing font-display: swap already renders fallback fonts; returning an error is fine
"script" / "style" <script>, <link rel=stylesheet> Nothing A fake script or stylesheet can break the page worse than a missing one
"audio" / "video" Media elements Nothing, or a "not available offline" UI in the player Media uses range requests; never serve cached full bodies to Range requests without handling 206
"" (empty string) fetch(), XMLHttpRequest, navigator.sendBeacon() JSON error with a clear status Destination-based routing never sees fetch() calls. Route API calls by URL instead

The last row is the classic bug: a worker that serves the placeholder SVG for destination === "image" doesn't affect fetch("/avatar.png") in your JavaScript, because script-initiated fetches have an empty destination. If your code fetches images as blobs, route them by URL.

The placeholder itself should be tiny, neutral, and accessible. Screen readers use the <img> element's alt, not the SVG's content, so the fallback doesn't change the accessibility of the page:

img/offline-placeholder.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300" preserveAspectRatio="xMidYMid slice">
  <rect width="400" height="300" fill="#e8e8ec"/>
  <g fill="none" stroke="#9a9aa3" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
    <rect x="130" y="95" width="140" height="110" rx="10"/>
    <path d="M140 190l40-45 30 30 20-20 30 35"/>
    <circle cx="235" cy="125" r="10"/>
  </g>
</svg>

For API calls, a synthetic 503 with a JSON body and a marker header (SW-Cache-Status: offline-miss in the worker above) lets your data layer tell "the server said no" apart from "we never reached the server". Never answer an API miss with the offline HTML page. Code that calls response.json() on it throws a SyntaxError that looks like a server bug.

Workbox: offlineFallback recipe and setCatchHandler

With Workbox, the offlineFallback() recipe from workbox-recipes implements the destination-based fallbacks in a few lines. Reading its source shows exactly what it does:

  • During install, it addAll()s the fallback URLs into a cache named workbox-offline-fallbacks. pageFallback defaults to offline.html, and imageFallback and fontFallback are optional.
  • It registers a catch handler with setCatchHandler(). The catch handler runs whenever any route's handler throws. It returns the page fallback for destination === "document", the image fallback for "image", the font fallback for "font", and Response.error() for everything else. It checks the precache first (matchPrecache()), then its own cache.
sw.js (Workbox recipe)
import { offlineFallback } from "workbox-recipes";
import { setDefaultHandler } from "workbox-routing";
import { NetworkOnly } from "workbox-strategies";

// Requests that no other route handles go to the network; failures hit the catch handler.
setDefaultHandler(new NetworkOnly());

offlineFallback({
  pageFallback: "/offline.html",
  imageFallback: "/img/offline-placeholder.svg",
});
sw.js (Workbox, custom catch handler)
import { matchPrecache, precacheAndRoute } from "workbox-precaching";
import { setCatchHandler } from "workbox-routing";

precacheAndRoute(self.__WB_MANIFEST); // include offline.html and the placeholder SVG

setCatchHandler(async ({ request }) => {
  switch (request.destination) {
    case "document":
      return (await matchPrecache("/offline.html")) ?? Response.error();
    case "image":
      return (await matchPrecache("/img/offline-placeholder.svg")) ?? Response.error();
    case "":
      // fetch() calls: a machine-readable offline response instead of HTML.
      // (new Response + JSON.stringify rather than Response.json(), which needs Safari 17+.)
      return new Response(JSON.stringify({ error: "offline" }), {
        status: 503,
        headers: { "Content-Type": "application/json", "SW-Cache-Status": "offline-miss" },
      });
    default:
      return Response.error();
  }
});

Two details of the recipe matter in production. First, its install listener calls cache.addAll() with plain URLs, so the fallback is fetched with the default cache mode and can be captured from a stale HTTP-cache copy. Precache the fallback files with a revision as well: the catch handler checks matchPrecache() first, so the revisioned copy wins. Second, the catch handler only runs for requests that match a route and whose handler fails. Requests that no route matches, and no default handler covers, never reach it. That's why the recipe example sets a NetworkOnly default handler. Workbox Fundamentals explains routing order and default handlers.

Detecting connectivity

What navigator.onLine really means

The HTML Standard defines navigator.onLine asymmetrically. It "returns false if the user agent is definitely offline (disconnected from the network)" and "returns true if the user agent might be online". The spec adds bluntly: "This attribute is inherently unreliable. A computer can be connected to a network without having Internet access."

In practice:

  • false is reliable. No network interface is up (airplane mode, Wi-Fi off, cable unplugged), or the user forced offline mode. Requests will fail. Show offline UI immediately.
  • true means very little. A LAN connection counts as online even when the router has no upstream connection. MDN notes that virtualization software with always-connected virtual adapters keeps it true. On Windows, the status depends on the OS reaching a Microsoft connectivity-check server, which firewalls and VPNs can block, so it can report offline while the internet works.
  • Per-browser quirks exist. MDN's compatibility data notes that Chrome on Linux always returns true. Firefox's Work Offline mode forces false regardless of real connectivity, and since Firefox 41 the value otherwise follows actual network connectivity on Windows and macOS.
  • It's available in workers. WorkerNavigator.onLine exists in dedicated and service workers in all engines.

MDN's guidance matches the spec: don't disable features based on onLine, and only use it as a hint. Treat onLine === false as authoritative, and treat true as "go and check".

The online and offline events

When the value changes, the browser fires offline or online on the Window. The HTML Standard also specifies them on WorkerGlobalScope, but support there is uneven: MDN's data lists the worker events for Firefox and Safari but not for Chromium. In a service worker they are irrelevant in any case. The worker is terminated when idle, so it isn't running to receive the event when connectivity changes. Use Background Sync (Chromium) or page-side triggers for "do this when back online".

Three behaviors to design around:

  • online fires on interface changes, not on internet reachability. Joining a captive-portal Wi-Fi fires online. Treat it as a trigger to check, not as proof.
  • Events can flap. Moving between Wi-Fi and cellular can produce offline then online within a second. Debounce UI transitions (the monitor below waits for a confirming probe) so the banner doesn't flash.
  • Events don't fire for lie-fi. A connection that degrades to 90% packet loss never changes onLine. Only timeouts reveal it.

Heartbeat checks that tell the truth

A heartbeat is a tiny request to your own origin that answers the only question that matters: can this device reach my server right now? Design it carefully:

  • Same origin, dedicated endpoint. For example HEAD /__health returning 204 No Content with Cache-Control: no-store. A 204 with no body is cheap, and only your server produces it.
  • Check the exact status. Captive portals answer arbitrary URLs with a 200 HTML login page, or redirect to one. Following that redirect to another origin fails CORS and throws, which is also fine. Accepting "any 2xx" as healthy would report a captive portal as online. Accept only 204.
  • Bypass every cache. Use cache: "no-store" for the HTTP cache. The service worker must also let the request through: the worker above returns early for HEALTH_PATH without calling respondWith(). A worker that answers /__health from a cache reports "online" forever. On Chromium you can go further and use the Static Routing API to send the path straight to the network without even starting the worker.
  • Time out. A heartbeat that hangs is lie-fi. Abort it after 3–5 seconds and treat the timeout as "degraded", not "offline".
  • Back off. Poll only while you believe the device is offline or degraded. Use exponential backoff with jitter (2 s, 4 s, 8 s … capped at 60 s), stop while the page is hidden, and check immediately on online, visibilitychange and user-initiated retries.
  • Don't poll when healthy. While things work, real requests are the heartbeat. Feed their outcomes into the monitor (next section) and skip polling entirely.

Passive detection from real requests

Every real request tells you something about connectivity. A thin wrapper around fetch() can report its outcome (success, failure, timeout, and duration) to the monitor, and the service worker can report outcomes it sees through a BroadcastChannel. Passive signals detect problems at the moment the user hits them, at no extra cost. The heartbeat then only has to confirm a suspected outage and detect recovery.

On the worker side, reporting is a few lines in the network-first helper shown earlier. BroadcastChannel is available in service workers in Chrome 54, Firefox 38 and Safari 15.4 and later:

sw.js (reporting request outcomes)
const connectivityChannel = new BroadcastChannel("connectivity");

function reportOutcome(outcome) {
  // Fire-and-forget: every open page with a ConnectivityMonitor receives it.
  connectivityChannel.postMessage(outcome); // { ok, timedOut, durationMs }
}

// In networkFirst(): measure around the network promise.
//   const started = Date.now();
//   network.then(() => reportOutcome({ ok: true, durationMs: Date.now() - started }),
//                () => reportOutcome({ ok: false }));
//   ...and when the timer wins: reportOutcome({ ok: false, timedOut: true });

The Network Information API

navigator.connection (the Network Information API) exposes estimates of connection quality. It's Chromium-only. Safari has never shipped it, desktop Firefox removed it after version 31, and Firefox for Android removed it in version 99. It's also a quality signal, not a connectivity signal: it can't tell you whether your server is reachable.

Property Values Chromium support Notes
effectiveType "slow-2g", "2g", "3g", "4g" Chrome 61 desktop, Chrome 38 Android Derived from recently observed RTT and throughput. "4g" means "faster than the 3g thresholds", so fiber broadband also reports "4g"
rtt Milliseconds, rounded to the nearest 25 ms Chrome 61 / Android 38 MDN notes Chromium caps it at 3000 ms as an anti-fingerprinting measure
downlink Mbit/s, rounded to the nearest 25 kbit/s Chrome 61 / Android 38 Capped at 10 Mbit/s in Chromium for the same reason
saveData true / false Chrome 65 The user asked for reduced data use. Honor it (skip warm caching, lower image quality)
type "wifi", "cellular", "ethernet", "none", … ChromeOS and Android only Not exposed on Windows, macOS or Linux desktop
change event fires when any value changes Chrome 61 / Android 38 Use it as another trigger to re-check

The spec's effective connection type thresholds are: slow-2g for an RTT of 2000 ms or more (or throughput up to 50 kbit/s), 2g for 1400 ms or more (up to 70 kbit/s), 3g for 270 ms or more (up to 700 kbit/s), and 4g for everything better. Use effectiveType and saveData to adapt (smaller images, no speculative prefetching, longer timeouts), never to decide whether the device is online.

A complete connectivity monitor

The module below combines every signal: navigator.onLine and its events, passive reports from real requests (including reports the service worker broadcasts), an on-demand heartbeat with a timeout, backoff polling only while unhealthy, a pause while the page is hidden, and Network Information hints where available. It exposes a three-state model (online, degraded, offline) plus a change event:

connectivity.js
const HEALTH_URL = "/__health"; // must return 204 + Cache-Control: no-store; the SW must not answer it
const HEARTBEAT_TIMEOUT_MS = 4000;
const SLOW_MS = 2500; // slower than this counts as degraded
const FAILURES_BEFORE_OFFLINE = 2; // hysteresis against flapping
const MIN_BACKOFF_MS = 2000;
const MAX_BACKOFF_MS = 60000;

/** AbortSignal.timeout() where available; manual fallback elsewhere. */
export function timeoutSignal(ms) {
  if (typeof AbortSignal.timeout === "function") return AbortSignal.timeout(ms);
  const controller = new AbortController();
  setTimeout(() => controller.abort(new DOMException("Timed out", "TimeoutError")), ms);
  return controller.signal;
}

/** Chrome 103-123 aborted AbortSignal.timeout() with "AbortError"; newer engines use "TimeoutError". */
export const isTimeout = (error) => error?.name === "TimeoutError" || error?.name === "AbortError";

export class ConnectivityMonitor extends EventTarget {
  #state = navigator.onLine ? "online" : "offline";
  #since = Date.now();
  #failures = 0;
  #backoff = MIN_BACKOFF_MS;
  #timer = null;
  #inFlight = null;

  constructor() {
    super();
    addEventListener("offline", () => {
      this.#set("offline", "offline-event");
      this.#ensurePolling();
    });
    addEventListener("online", () => this.check("online-event")); // online = "go and check"
    document.addEventListener("visibilitychange", () => {
      if (document.visibilityState === "hidden") {
        this.#clearTimer(); // no polling in background tabs
      } else if (this.#state !== "online") {
        this.check("visible");
      }
    });
    navigator.connection?.addEventListener?.("change", () => this.check("connection-change"));
    if ("BroadcastChannel" in self) {
      // The service worker posts { ok, timedOut, durationMs } for requests it made.
      new BroadcastChannel("connectivity").addEventListener("message", ({ data }) => this.report(data));
    }
    this.#ensurePolling();
  }

  get state() {
    return this.#state;
  }

  get since() {
    return this.#since;
  }

  /** Passive signal: call with the outcome of every real request. */
  report({ ok, timedOut = false, durationMs = 0 }) {
    if (ok) {
      this.#failures = 0;
      this.#set(durationMs > SLOW_MS ? "degraded" : "online", "request");
      return;
    }
    this.#failures += 1;
    if (timedOut && this.#state === "online") this.#set("degraded", "request-timeout");
    if (this.#failures >= FAILURES_BEFORE_OFFLINE) this.check("request-failures");
    this.#ensurePolling();
  }

  /** Active probe. Concurrent callers share one in-flight request. */
  check(reason = "manual") {
    this.#inFlight ??= this.#probe(reason).finally(() => {
      this.#inFlight = null;
    });
    return this.#inFlight;
  }

  async #probe(reason) {
    if (!navigator.onLine) {
      this.#set("offline", reason); // false is authoritative
      return this.#state;
    }
    const started = performance.now();
    try {
      // The query string defeats misbehaving intermediary caches; no-store handles the HTTP cache.
      const response = await fetch(`${HEALTH_URL}?t=${Date.now()}`, {
        method: "HEAD",
        cache: "no-store",
        credentials: "omit",
        signal: timeoutSignal(HEARTBEAT_TIMEOUT_MS),
      });
      // Captive portals answer with 200 HTML or redirects: only our own 204 proves reachability.
      if (response.status !== 204) throw new Error(`health check returned ${response.status}`);
      this.#failures = 0;
      const slow = performance.now() - started > SLOW_MS || this.#isSlowNetworkHint();
      this.#set(slow ? "degraded" : "online", reason);
    } catch (error) {
      this.#failures += 1;
      const stillHopeful = isTimeout(error) && this.#failures < FAILURES_BEFORE_OFFLINE;
      this.#set(stillHopeful ? "degraded" : "offline", reason);
    }
    this.#ensurePolling();
    return this.#state;
  }

  #isSlowNetworkHint() {
    const type = navigator.connection?.effectiveType; // Chromium only
    return type === "slow-2g" || type === "2g";
  }

  #set(state, reason) {
    if (state === "online") {
      this.#backoff = MIN_BACKOFF_MS;
      this.#clearTimer(); // healthy: real requests are the heartbeat
    }
    if (state === this.#state) return;
    const previous = this.#state;
    this.#state = state;
    this.#since = Date.now();
    this.dispatchEvent(new CustomEvent("change", { detail: { state, previous, reason, since: this.#since } }));
  }

  /** Poll with exponential backoff + jitter, only while unhealthy and visible. */
  #ensurePolling() {
    if (this.#state === "online" || this.#timer !== null || document.visibilityState === "hidden") return;
    const delay = this.#backoff + Math.random() * 0.3 * this.#backoff;
    this.#backoff = Math.min(this.#backoff * 2, MAX_BACKOFF_MS);
    this.#timer = setTimeout(() => {
      this.#timer = null;
      this.check("poll");
    }, delay);
  }

  #clearTimer() {
    clearTimeout(this.#timer);
    this.#timer = null;
  }
}

export const connectivity = new ConnectivityMonitor();

Pair it with a fetch wrapper that reports every outcome, including the service worker's SW-Cache-Status marker. A response the worker answered from cache "succeeded" from the page's point of view, but it's evidence of a network problem:

net.js
import { connectivity, isTimeout, timeoutSignal } from "./connectivity.js";

/**
 * fetch() with a timeout, optional caller cancellation, and connectivity reporting.
 * @param {RequestInfo} input
 * @param {RequestInit & {timeoutMs?: number}} [options]
 */
export async function request(input, { timeoutMs = 8000, signal, ...init } = {}) {
  const timeout = timeoutSignal(timeoutMs);
  // AbortSignal.any(): Chrome 116, Firefox 124, Safari 17.4. Without it, prefer the caller's signal.
  const combined = signal ? (AbortSignal.any ? AbortSignal.any([signal, timeout]) : signal) : timeout;
  const started = performance.now();
  try {
    const response = await fetch(input, { ...init, signal: combined });
    const cacheStatus = response.headers.get("SW-Cache-Status"); // set by our service worker
    connectivity.report({
      ok: cacheStatus === null,
      timedOut: cacheStatus === "stale-timeout",
      durationMs: performance.now() - started,
    });
    return response;
  } catch (error) {
    if (signal?.aborted) throw error; // the caller cancelled: not a connectivity signal
    connectivity.report({ ok: false, timedOut: isTimeout(error) });
    throw error;
  }
}

AbortSignal.timeout() error names changed in Chrome 124

MDN's compatibility data records that Chrome 103 to 123 aborted AbortSignal.timeout() signals with an AbortError instead of the specified TimeoutError. Chrome 124 and later, Firefox 100 and later, and Safari 16 and later use TimeoutError. If you distinguish user cancellation from timeouts, check the caller's own signal.aborted first (as net.js does), rather than relying on the error name alone.

UI patterns for offline and degraded states

The right pattern depends on how long the state lasts and whether the user has to act. A useful rule: ambient state gets a persistent, quiet indicator; transitions get a brief notice; per-item state is shown on the item.

Situation Pattern Persistence ARIA
Device offline Banner at the edge of the viewport Until back online role="status" (polite)
Connection degraded (lie-fi) Same banner, warning style, dismissible Until recovered or dismissed role="status"
Back online Short confirmation ("Back online, sending 3 changes") 3–5 seconds role="status"
An action was queued Per-item "Waiting to send" label, pending style Until sent Text on the item; announce once
A queued action failed permanently Per-item error with Retry/Delete, plus a notice Until resolved role="alert" for the notice (user-initiated action failed)
Data shown from cache "Updated 3 hours ago" freshness label While the data is displayed Plain text; <time datetime>
Action impossible offline Keep the control visible, explain on activation While offline aria-disabled="true" + description

An offline banner component

The custom element below subscribes to the connectivity monitor and the outbox channel, and renders one of three messages. It is keyboard-accessible, announces changes through an always-present live region (a live region that is itself hidden when its text changes may not be announced), respects prefers-reduced-motion and the safe area on notched devices, and can be themed with CSS custom properties from the host page:

offline-banner.js
import { connectivity } from "./connectivity.js";

const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;

const MESSAGES = {
  offline: (pending) =>
    pending > 0
      ? `You're offline. ${plural(pending, "change")} will be sent when you reconnect.`
      : "You're offline. You can keep using content saved on this device.",
  degraded: () => "Your connection is slow. Some content may be out of date.",
  restored: (pending) => (pending > 0 ? `Back online. Sending ${plural(pending, "change")}…` : "You're back online."),
};

const template = document.createElement("template");
template.innerHTML = `
  <style>
    :host { position: fixed; inset-inline: 0; inset-block-end: 0; z-index: 1000; pointer-events: none;
            padding: 0 12px max(12px, env(safe-area-inset-bottom)); }
    .banner { pointer-events: auto; display: flex; gap: 12px; align-items: center; max-width: 40rem;
              margin: 0 auto; padding: 10px 14px; border-radius: 10px;
              font: 500 0.95rem/1.4 system-ui, sans-serif;
              background: var(--offline-banner-bg, #2b2b30); color: var(--offline-banner-fg, #fff);
              box-shadow: 0 4px 16px rgb(0 0 0 / 0.25);
              transition: transform .25s ease, opacity .25s ease, visibility 0s; }
    .banner[data-state="degraded"] { background: var(--offline-banner-warn-bg, #6b4e00); }
    .banner[data-state="restored"] { background: var(--offline-banner-ok-bg, #1e5b33); }
    .banner.hidden { transform: translateY(120%); opacity: 0; visibility: hidden;
                     transition: transform .25s ease, opacity .25s ease, visibility 0s linear .25s; }
    .message { flex: 1; margin: 0; }
    button { font: inherit; color: inherit; background: transparent; border: 1px solid currentColor;
             border-radius: 6px; padding: 4px 10px; cursor: pointer; }
    button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
    .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%);
               white-space: nowrap; }
    @media (prefers-reduced-motion: reduce) { .banner, .banner.hidden { transition: none; } }
  </style>
  <div class="sr-only" role="status" aria-live="polite" aria-atomic="true"></div>
  <div class="banner hidden" part="banner">
    <p class="message" aria-hidden="true"></p>
    <button type="button" class="action"></button>
  </div>`;

class OfflineBanner extends HTMLElement {
  #shadow;
  #pending = 0;
  #shown = null; // "offline" | "degraded" | "restored" | null
  #dismissed = false;
  #hideTimer = 0;
  #channel = "BroadcastChannel" in self ? new BroadcastChannel("outbox") : null;

  connectedCallback() {
    if (!this.#shadow) {
      this.#shadow = this.attachShadow({ mode: "open" });
      this.#shadow.append(template.content.cloneNode(true));
      this.#shadow.querySelector(".action").addEventListener("click", () => this.#onAction());
    }
    connectivity.addEventListener("change", this.#onConnectivity);
    this.#channel?.addEventListener("message", this.#onOutbox);
    // Outbox is optional: the global comes from outbox-core.js when the app uses offline forms.
    self.Outbox?.list()
      .then((items) => this.#onOutbox({ data: { pending: items.filter((i) => i.state === "pending").length } }))
      .catch(() => {});
    if (connectivity.state !== "online") this.#show(connectivity.state);
  }

  disconnectedCallback() {
    connectivity.removeEventListener("change", this.#onConnectivity);
    this.#channel?.removeEventListener("message", this.#onOutbox);
    clearTimeout(this.#hideTimer);
  }

  #onConnectivity = ({ detail: { state, previous } }) => {
    if (state === "online") {
      // "Back online" only after a real outage; recovering from "degraded" just clears the notice.
      if (previous === "offline") this.#show("restored");
      else if (this.#shown) this.#hide();
      return;
    }
    if (state === "degraded" && this.#dismissed) return;
    this.#dismissed = false;
    this.#show(state);
  };

  #onOutbox = ({ data }) => {
    if (typeof data?.pending !== "number") return;
    this.#pending = data.pending;
    if (this.#shown) this.#render(this.#shown, { announce: false }); // update the count silently
  };

  #onAction() {
    if (this.#shown === "offline") {
      connectivity.check("user-retry");
    } else {
      this.#dismissed = this.#shown === "degraded";
      this.#hide();
    }
  }

  #show(kind) {
    clearTimeout(this.#hideTimer);
    this.#render(kind, { announce: kind !== this.#shown });
    if (kind === "restored") this.#hideTimer = setTimeout(() => this.#hide(), 4000);
  }

  #render(kind, { announce }) {
    const text = MESSAGES[kind](this.#pending);
    const banner = this.#shadow.querySelector(".banner");
    const action = this.#shadow.querySelector(".action");
    this.#shown = kind;
    banner.dataset.state = kind;
    banner.classList.remove("hidden");
    this.#shadow.querySelector(".message").textContent = text;
    action.textContent = kind === "offline" ? "Retry" : "Dismiss";
    action.hidden = kind === "restored";
    if (announce) this.#shadow.querySelector("[role=status]").textContent = text;
  }

  #hide() {
    clearTimeout(this.#hideTimer);
    this.#shown = null;
    this.#shadow.querySelector(".banner").classList.add("hidden");
  }
}

customElements.define("offline-banner", OfflineBanner);

Use it by adding <offline-banner></offline-banner> once, near the end of <body>, and loading the module. Theme it from the page:

index.html (excerpt)
<style>
  offline-banner { --offline-banner-bg: #202124; --offline-banner-warn-bg: #7a5200; }
  /* Keep the last focusable element in view when the banner is shown (WCAG 2.2 Focus Not Obscured). */
  body { scroll-padding-block-end: 72px; }
</style>
<offline-banner></offline-banner>
<script type="module" src="/js/offline-banner.js"></script>

Design decisions behind the component:

  • No "you're online" chrome. Being online is the normal state. The banner only appears for problems, and briefly for recovery.
  • role="status", not role="alert". Connectivity changes aren't errors the user caused, and alert interrupts whatever a screen reader is reading. Save alert for failures of an action the user just took. See WCAG's status messages criterion.
  • The live region is separate from the visual banner. The visible text is aria-hidden to avoid double announcements. The live region is never hidden, so updates are announced reliably.
  • visibility: hidden when hidden. Opacity alone would leave the button focusable and exposed to assistive technology while invisible. The delayed visibility transition keeps the slide-out animation.
  • Counts update silently. When the outbox count changes while the banner is visible, the text updates without a new announcement, which avoids a chatty screen reader during replay.
  • The degraded state is dismissible. Lie-fi can last a whole commute. Once dismissed, the banner stays hidden until a different state occurs.

Toasts for transitions

Toasts suit events ("Saved for offline", "3 changes sent"), not states. Two rules keep them useful. First, coalesce: during replay, show "3 changes sent" once, not three toasts. Second, never make a toast the only place information lives. A toast disappears after a few seconds. A queued item's status must also be visible on the item itself.

Disabling vs queuing actions

Most actions should keep working offline by queuing. Some genuinely can't: anything that needs a server's answer before it can complete. Classify every action in your app:

Action Offline behavior Why
Read cached content Allow That's what the cache is for
Create or edit own content (notes, comments, drafts) Queue with optimistic UI Server acceptance is very likely; conflicts are resolvable
Delete Queue, with undo Irreversible on the server, so give users a window to change their mind
Search Local search over cached data, clearly labeled Partial results beat no results
Payments, purchases, bookings Block with an explanation Needs real-time server confirmation; never "queue" money
Sign-in, sign-up, password change Block with an explanation Needs the server; queuing credentials is a security risk
Large uploads Queue with a size warning, or block on metered connections Storage and data costs
Export, print, share Allow if client-side Many exports need no server

When you must block, don't use the disabled attribute on its own. A disabled button can't receive focus, so keyboard and screen reader users can't discover why it doesn't work. Use aria-disabled="true", keep the button focusable, and explain on activation:

online-only-action.js
import { connectivity } from "./connectivity.js";

/**
 * Marks a control as unavailable while offline, without removing it from the tab order.
 * @param {HTMLButtonElement} button
 * @param {HTMLElement} hint An element with an id, referenced by aria-describedby.
 */
export function onlineOnly(button, hint) {
  button.setAttribute("aria-describedby", hint.id);
  const update = () => {
    const offline = connectivity.state === "offline";
    button.setAttribute("aria-disabled", String(offline));
    hint.hidden = !offline;
  };
  // Capture phase: runs before the button's own click handlers.
  button.addEventListener(
    "click",
    (event) => {
      if (button.getAttribute("aria-disabled") !== "true") return;
      event.preventDefault();
      event.stopImmediatePropagation(); // don't run the real action
      hint.hidden = false;
      connectivity.check("user-action"); // maybe we're back already
    },
    { capture: true }
  );
  connectivity.addEventListener("change", update);
  update();
}

Style [aria-disabled="true"] to look unavailable, and write the hint to say when the action will work again ("Available when you're back online"), not only that it's unavailable.

Queued state indicators

Every queued item needs visible state on the item itself. The vocabulary is small:

Item state Label Visual
sending "Sending…" Normal item, subtle spinner
queued "Waiting to send" Muted or dashed outline, clock icon
sent none (or a brief checkmark) Normal item
rejected "Not sent: [reason]" with Retry and Delete Error color and icon, plus text

Add a global count ("3 changes waiting to send") in the banner or the app header, so users know unsent work exists before they close the app. For high-stakes data, use a beforeunload prompt when the queue is non-empty and Background Sync isn't available. Browsers show a generic confirmation dialog, not your text.

"Last updated" freshness indicators

Cached data shown without a date is a lie by omission. A stock price, a delivery status or a team roster from yesterday can mislead. Show the age of every cached dataset, and emphasize it when the data is stale and the app can't refresh it. The worker above stamps an SW-Fetched-At header on every cached API response. The page reads it (falling back to the server's Date header) and renders a relative time that keeps itself current:

freshness.js
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
const UNITS = [
  ["year", 31_536_000],
  ["month", 2_592_000],
  ["week", 604_800],
  ["day", 86_400],
  ["hour", 3_600],
  ["minute", 60],
];

/** "now", "5 minutes ago", "yesterday", "3 weeks ago" in the user's locale. */
export function formatAge(date, now = Date.now()) {
  const seconds = Math.round((date.getTime() - now) / 1000); // negative = in the past
  if (Math.abs(seconds) < 45) return rtf.format(0, "second"); // "now"
  for (const [unit, size] of UNITS) {
    if (Math.abs(seconds) >= size) return rtf.format(Math.round(seconds / size), unit);
  }
  return rtf.format(Math.round(seconds / 60), "minute"); // 45-59 s: "1 minute ago"
}

/** When was this response fetched from the network? */
export function fetchedAt(response) {
  const value = response.headers.get("SW-Fetched-At") ?? response.headers.get("Date");
  const date = value ? new Date(value) : null;
  return date && !Number.isNaN(date.getTime()) ? date : null;
}

const tracked = new Set();
let ticker = 0;

/**
 * Render "Updated 5 minutes ago" into a <time> element and keep it current.
 * @param {HTMLTimeElement} el
 * @param {Date} date
 * @param {{staleAfterMs?: number}} [options]
 */
export function showFreshness(el, date, { staleAfterMs = 60 * 60 * 1000 } = {}) {
  el.dateTime = date.toISOString();
  el.title = date.toLocaleString(); // exact time on hover
  el.dataset.staleAfter = String(staleAfterMs);
  update(el);
  tracked.add(el);
  ticker ||= setInterval(() => {
    for (const node of tracked) (node.isConnected ? update(node) : tracked.delete(node));
    if (tracked.size === 0) ticker = (clearInterval(ticker), 0);
  }, 30_000);
}

function update(el) {
  const date = new Date(el.dateTime);
  el.textContent = `Updated ${formatAge(date)}`;
  el.toggleAttribute("data-stale", Date.now() - date.getTime() > Number(el.dataset.staleAfter));
}

Use it wherever cached data is rendered:

feed.js
import { request } from "./net.js";
import { fetchedAt, showFreshness } from "./freshness.js";

export async function loadFeed(container, timeLabel) {
  const response = await request("/api/feed", { timeoutMs: 6000 });
  if (response.status === 503 && response.headers.get("SW-Cache-Status") === "offline-miss") {
    container.textContent = "The feed isn't available offline yet. Open it once while online to save it.";
    return;
  }
  const items = await response.json();
  renderItems(container, items); // your rendering code
  const date = fetchedAt(response);
  if (date) showFreshness(timeLabel, date, { staleAfterMs: 15 * 60 * 1000 });
}

For server-rendered HTML, embed the generation time in the page itself (<meta name="generated-at" content="2026-09-25T08:14:00Z">). A cached copy then carries its own age, with no service worker coordination needed. Also consider the Age header when a CDN served the response: Date is when the origin generated it, which can be much older than when the browser fetched it.

Optimistic UI

Optimistic UI applies a change to the interface immediately, before the server confirms it, and reconciles later. Offline, it's the only way to make creating content feel normal. Three techniques make it safe:

  1. Client-generated IDs. Create the record's ID in the browser (crypto.randomUUID(), supported in Chrome 92, Firefox 95 and Safari 15.4 and later). The server accepts it as the primary key, or maps it. You never have to swap a temporary ID for a server ID in the UI, in URLs, or in later queued requests that reference the record.
  2. Explicit pending state. Optimistic doesn't mean pretending. The item appears instantly, but it's marked "Sending…" or "Waiting to send" until confirmed.
  3. Defined rollback. If the server rejects the change permanently (validation error, permission denied), remove or mark the item, and give the user their input back. Never silently drop typed text.

The example below uses the outbox client from the offline forms section. The same ID serves as the record ID, the DOM key, and the idempotency key:

comments.js
import { RejectedError, submit } from "./outbox-client.js";

const list = document.querySelector("#comments");
const form = document.querySelector("#comment-form");
const errorBox = document.querySelector("#comment-error"); // has role="alert"

const LABELS = {
  sending: "Sending…",
  queued: "Waiting to send",
  sent: "",
  rejected: "Not sent. The server refused this comment.",
};

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const text = String(new FormData(form).get("text") ?? "").trim();
  if (!text) return;

  const id = crypto.randomUUID();
  const item = renderComment(id, text, "sending"); // optimistic: visible immediately
  list.prepend(item);
  form.reset();
  errorBox.textContent = "";

  try {
    const result = await submit({ id, url: "/api/comments", json: { id, text }, meta: { kind: "comment", id } });
    setState(item, result.status === "sent" ? "sent" : "queued");
  } catch (error) {
    item.remove(); // rollback
    form.elements.namedItem("text").value = text; // give the text back
    errorBox.textContent =
      error instanceof RejectedError ? "The server rejected this comment." : "Couldn't save your comment.";
  }
});

// Queued comments resolve later, possibly in another tab or from the service worker.
new BroadcastChannel("outbox").addEventListener("message", ({ data }) => {
  if (data.meta?.kind !== "comment") return;
  const item = list.querySelector(`[data-id="${CSS.escape(data.meta.id)}"]`);
  if (item && (data.type === "sent" || data.type === "rejected")) setState(item, data.type);
});

function renderComment(id, text, state) {
  const li = document.createElement("li");
  li.dataset.id = id;
  const body = document.createElement("p");
  body.textContent = text; // user content: never innerHTML
  const status = document.createElement("span");
  status.className = "comment-status";
  li.append(body, status);
  setState(li, state);
  return li;
}

function setState(li, state) {
  li.dataset.state = state; // style [data-state="queued"] etc. in CSS
  li.querySelector(".comment-status").textContent = LABELS[state];
}

Optimistic UI gets harder when the same record can be edited on several devices. Last-write-wins, field-level merges, operational transforms and CRDTs are covered in Offline-First Data & Sync.

Save for offline UX

The storage mechanics of user-initiated caching (a dedicated cache, atomic addAll(), an index protected by Web Locks, shared-asset cleanup) are covered in Precaching & Runtime Caching. The user-facing side needs:

  • A clear toggle per item. "Save for offline" becomes "Saved" with a checkmark. Selecting it again offers removal.
  • Progress for anything non-trivial. Show the number of files downloaded, or a percentage when you know the total size.
  • A failure state that explains itself. "Not enough storage" differs from "couldn't download while offline".
  • A management screen. List saved items with their size and date, a remove button for each, and the total space used.
  • An eviction warning. Unless navigator.storage.persisted() is true, tell users the browser may remove saved content when the device runs low on space.
saved-items-panel.js
import { listSaved, removeFromOffline, saveForOffline } from "./save-for-offline.js";

const format = new Intl.NumberFormat(undefined, { style: "unit", unit: "megabyte", maximumFractionDigits: 1 });

export async function renderStorageSummary(el) {
  if (!navigator.storage?.estimate) return;
  const [{ usage = 0, quota = 0 }, persisted] = await Promise.all([
    navigator.storage.estimate(),
    navigator.storage.persisted?.() ?? Promise.resolve(false),
  ]);
  el.textContent =
    `Using ${format.format(usage / 1e6)} of about ${format.format(quota / 1e6)} available. ` +
    (persisted
      ? "Saved items are protected from automatic cleanup."
      : "Your browser may remove saved items if the device runs low on space.");
}

export function wireSaveButton(button, { url, title, assets }) {
  button.addEventListener("click", async () => {
    button.disabled = true; // prevent double saves; the label explains the state
    button.textContent = "Saving…";
    try {
      await saveForOffline({ url, title, assets });
      button.textContent = "Saved for offline";
      button.setAttribute("aria-pressed", "true");
    } catch (error) {
      button.textContent = "Save for offline";
      alertUser(error.message); // e.g. "There isn't enough free storage to save this page."
    } finally {
      button.disabled = false;
    }
  });
}

export async function renderSavedList(listEl) {
  listEl.replaceChildren();
  for (const item of await listSaved()) {
    const li = document.createElement("li");
    const link = Object.assign(document.createElement("a"), { href: item.url, textContent: item.title });
    const when = new Date(item.savedAt).toLocaleDateString();
    const remove = Object.assign(document.createElement("button"), { type: "button", textContent: "Remove" });
    remove.setAttribute("aria-label", `Remove "${item.title}" from this device`);
    remove.addEventListener("click", async () => {
      await removeFromOffline(item.url);
      li.remove();
    });
    li.append(link, ` · saved ${when} `, remove);
    listEl.append(li);
  }
}

function alertUser(message) {
  document.querySelector("#save-error").textContent = message; // element with role="alert"
}

estimate() returns an approximation. Browsers deliberately add noise and padding (for example for opaque responses), so present the numbers as "about".

Offline forms with Background Sync and a fallback

A form that fails offline loses the user's work. A form that queues offline keeps it. The standard design is an outbox: every write goes into a durable queue in IndexedDB first, and a replayer drains the queue whenever there is a chance of success. Background Sync is the best replay trigger because the browser fires it even after the user has closed the app, but it's Chromium-only. Safari and Firefox need page-side triggers, so a production outbox always has both.

sequenceDiagram
    participant UI as Page UI
    participant OB as Outbox (IndexedDB)
    participant SW as Service worker
    participant API as Server
    UI->>API: POST /api/comments (Idempotency-Key)
    API--xUI: network error or timeout
    UI->>OB: add entry (same key)
    UI->>SW: sync.register("outbox")
    Note over UI: item shows "Waiting to send"
    Note over SW: later, connectivity returns
    SW->>OB: read pending entries in order
    SW->>API: replay POST (same key)
    API-->>SW: 201 Created (or the stored result of a duplicate)
    SW->>OB: delete entry
    SW-->>UI: BroadcastChannel "sent"

Idempotency keys make replays safe

A timed-out request is ambiguous. The request may never have reached the server, or the server may have committed the write and the response got lost on the way back. If you replay without protection, the second case creates a duplicate comment, order or payment. The fix is an idempotency key: a client-generated unique ID that the server records together with the result of the first successful execution. A replay with a known key returns the stored result without executing the write again.

The Idempotency-Key request header was specified in an IETF HTTPAPI working group Internet-Draft. Its last revision (-07, October 2025) expired without becoming an RFC, but it documents the common convention well. The value is a Structured Field String, so it's sent quoted (Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"), and the draft recommends a UUID or similar random value. It also suggests status codes for the failure cases: 400 when a required key is missing, 422 when a key is reused with a different payload, and 409 when a request arrives while the original request with the same key is still being processed.

Using the record's own ID as the key (the crypto.randomUUID() from the optimistic UI section) gives you idempotency for free when the server uses it as the primary key: a second INSERT of the same ID is a no-op that returns the existing row.

The replayer must classify every response, because retrying a permanent failure forever blocks the queue, and dropping a transient failure loses data:

Response Meaning Outbox action
TypeError from fetch() Never reached the server, or the connection died Keep the entry, count the attempt, stop draining (preserves order), retry later
Timeout (TimeoutError/AbortError) Unknown whether the server committed Keep, stop, retry later with the same key
2xx Accepted (possibly a deduplicated replay) Delete the entry, notify pages
opaqueredirect (with redirect: "manual") or 401 The session expired; the server wants a login Keep, pause the outbox, ask the user to sign in
408, 425, 429, 5xx except 501 Transient server-side condition Keep, stop, retry later (honor Retry-After)
409 Same key still in flight on the server Keep, stop, retry later
Other 4xx (400, 403, 404, 412, 422) The server will never accept this payload Mark rejected, keep the payload, show it to the user with Retry and Delete

Replaying with redirect: "manual" matters for cookie-based apps. An expired session often makes the API redirect to an HTML login page. With the default redirect: "follow", fetch() follows the redirect and returns the login page as a 200, which your outbox would count as a successful send. With "manual", the redirect surfaces as a response of type opaqueredirect (status 0), which you can recognize.

A shared outbox store

The outbox must be readable and writable from pages and from the service worker. The module below is a classic script, not an ES module, so the worker can load it with importScripts(). That works in every engine, whereas module service workers only arrived in Firefox 147. Pages load it with a plain <script> tag. It defines a single global, self.Outbox:

js/outbox-core.js
/*
 * Durable queue of HTTP writes, shared by pages and the service worker.
 * Pages:  <script src="/js/outbox-core.js"></script>
 * Worker: importScripts("/js/outbox-core.js");   (top level only)
 */
(() => {
  "use strict";

  const DB_NAME = "app-outbox";
  const DB_VERSION = 1;
  const STORE = "requests";
  const SYNC_TAG = "outbox";
  const LOCK_NAME = "outbox-replay";
  const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // give up on writes older than a week
  const REPLAY_TIMEOUT_MS = 30_000;
  const channel = "BroadcastChannel" in self ? new BroadcastChannel("outbox") : null;

  // ---- IndexedDB plumbing ---------------------------------------------------

  let dbPromise = null;

  function openDB() {
    dbPromise ??= new Promise((resolve, reject) => {
      const request = indexedDB.open(DB_NAME, DB_VERSION);
      request.onupgradeneeded = () => {
        const store = request.result.createObjectStore(STORE, { keyPath: "id" });
        store.createIndex("byCreatedAt", "createdAt"); // replay in creation order
      };
      request.onsuccess = () => {
        const db = request.result;
        // A newer version of the app wants to upgrade the schema: step aside.
        db.onversionchange = () => {
          db.close();
          dbPromise = null;
        };
        resolve(db);
      };
      request.onerror = () => {
        dbPromise = null;
        reject(request.error);
      };
      request.onblocked = () => console.warn("[outbox] schema upgrade blocked by another tab");
    });
    return dbPromise;
  }

  /**
   * Run one transaction. `fn` must only *issue* requests synchronously; the
   * promise resolves after the transaction commits, with the last request's result.
   */
  async function run(mode, fn) {
    const db = await openDB();
    // "strict": the write is flushed to disk before `complete` fires (Chrome 83, Firefox 126, Safari 15).
    const tx = db.transaction(STORE, mode, { durability: "strict" });
    const request = fn(tx.objectStore(STORE));
    await new Promise((resolve, reject) => {
      tx.oncomplete = resolve;
      tx.onerror = () => reject(tx.error);
      tx.onabort = () => reject(tx.error ?? new DOMException("Transaction aborted", "AbortError"));
    });
    return request?.result;
  }

  const list = () => run("readonly", (store) => store.index("byCreatedAt").getAll());
  const get = (id) => run("readonly", (store) => store.get(id));
  const save = (entry) => run("readwrite", (store) => store.put(entry));
  const remove = (id) => run("readwrite", (store) => store.delete(id));

  async function pendingCount() {
    return (await list()).filter((entry) => entry.state === "pending").length;
  }

  async function notify(type, entry, extra = {}) {
    if (!channel) return;
    const pending = await pendingCount().catch(() => undefined);
    channel.postMessage({ type, id: entry?.id, meta: entry?.meta, pending, ...extra });
  }

  // ---- Public API -------------------------------------------------------------

  /**
   * Queue a request. `body` may be a string or a Blob (IndexedDB stores both).
   * @param {{id: string, url: string, method?: string, headers?: [string, string][],
   *          body?: string|Blob|null, meta?: object, userId?: string}} input
   */
  async function add({ id, url, method = "POST", headers = [], body = null, meta = {}, userId = null }) {
    const entry = {
      id,
      url: new URL(url, self.location.href).href,
      method,
      headers,
      body,
      meta,
      userId,
      createdAt: Date.now(),
      attempts: 0,
      state: "pending", // "pending" | "rejected"
      lastError: null,
    };
    await run("readwrite", (store) => store.put(entry)); // put: re-adding the same id is harmless
    await notify("queued", entry);
    return entry;
  }

  /** Move a rejected entry back to pending (after the user fixed the cause). */
  async function retry(id) {
    const entry = await get(id);
    if (!entry) return;
    Object.assign(entry, { state: "pending", lastError: null });
    await save(entry);
    await notify("queued", entry);
  }

  async function discard(id) {
    const entry = await get(id);
    await remove(id);
    if (entry) await notify("discarded", entry);
  }

  /**
   * Drain the queue in order. Only one context (a tab or the worker) replays at
   * a time; others return { status: "busy" } immediately.
   * @returns {Promise<{status: "done"|"busy"|"retry"|"auth-required", sent?: number}>}
   */
  function replay({ reason = "manual" } = {}) {
    const locks = self.navigator?.locks; // Web Locks: Chrome 69, Firefox 96, Safari 15.4
    if (!locks) return drain(reason);
    return locks.request(LOCK_NAME, { ifAvailable: true }, (lock) =>
      lock ? drain(reason) : { status: "busy" }
    );
  }

  async function drain(reason) {
    let sent = 0;
    for (const entry of await list()) {
      if (entry.state !== "pending") continue;

      if (Date.now() - entry.createdAt > MAX_AGE_MS) {
        await markRejected(entry, "expired");
        continue;
      }

      let response;
      try {
        response = await fetch(entry.url, {
          method: entry.method,
          headers: [...entry.headers, ["Idempotency-Key", `"${entry.id}"`]],
          body: entry.body,
          credentials: "same-origin", // cookies travel with replays from the worker too
          cache: "no-store",
          redirect: "manual", // a login redirect must not look like success
          signal: AbortSignal.timeout ? AbortSignal.timeout(REPLAY_TIMEOUT_MS) : undefined,
        });
      } catch (error) {
        await countAttempt(entry, error.name || "NetworkError");
        return { status: "retry", sent, reason };
      }

      const outcome = classify(response);
      if (outcome === "sent") {
        await remove(entry.id);
        sent += 1;
        await notify("sent", entry, { status: response.status });
      } else if (outcome === "auth") {
        await countAttempt(entry, `auth (${response.status})`);
        await notify("auth-required", entry);
        return { status: "auth-required", sent, reason };
      } else if (outcome === "retry") {
        await countAttempt(entry, `HTTP ${response.status}`);
        return { status: "retry", sent, reason, retryAfter: response.headers.get("Retry-After") };
      } else {
        const detail = await response.text().catch(() => "");
        await markRejected(entry, `HTTP ${response.status}`, detail.slice(0, 500));
      }
    }
    return { status: "done", sent, reason };
  }

  function classify(response) {
    if (response.type === "opaqueredirect" || response.status === 401) return "auth";
    if (response.ok) return "sent";
    const { status } = response;
    if ([408, 409, 425, 429].includes(status) || (status >= 500 && status !== 501)) return "retry";
    return "rejected";
  }

  async function countAttempt(entry, error) {
    Object.assign(entry, { attempts: entry.attempts + 1, lastError: error, lastAttemptAt: Date.now() });
    await save(entry);
  }

  async function markRejected(entry, error, detail = "") {
    Object.assign(entry, { state: "rejected", lastError: error, detail });
    await save(entry);
    await notify("rejected", entry, { error, detail });
  }

  self.Outbox = Object.freeze({ SYNC_TAG, add, list, get, retry, discard, replay, pendingCount });
})();

Several decisions in this module are deliberate:

  • Order is preserved. The loop stops at the first transient failure instead of skipping to the next entry. Writes often depend on each other ("create note" then "rename note"), and replaying the second before the first fails on the server.
  • One replayer at a time. A page and the worker can both decide to replay at the same moment (an online event in the page and a sync event in the worker). navigator.locks.request() with ifAvailable: true makes the second caller return immediately instead of sending every entry twice. Idempotency keys make a double send harmless anyway, but the lock avoids the wasted traffic. Web Locks are available in service workers as WorkerNavigator.locks.
  • Durability is explicit. durability: "strict" asks the browser to flush the write to disk before complete fires. An outbox that loses its last entry when the device powers off defeats its purpose. Browsers that don't support the option ignore the unknown dictionary member.
  • Transactions only queue requests. An IndexedDB transaction commits automatically once no requests are pending at the end of a task. Awaiting unrelated promises (such as a fetch()) inside a transaction makes it commit early and the next request throws TransactionInactiveError. Every run() call is therefore one short transaction, and the network requests happen between transactions.
  • Rejected entries keep their payload. A permanent rejection is not a reason to throw away what the user typed. The UI shows the entry with the server's reason, and the user can fix and retry it, or discard it.

The IndexedDB page covers transactions, versioning and onversionchange in depth.

Submitting from the page

The page-side client tries the network first while it looks online, and falls back to the outbox on any network failure, timeout or transient error. It resolves with { status: "sent" } or { status: "queued" }, and rejects with a RejectedError only when the server gave a permanent answer. That's the contract the optimistic UI example relies on:

js/outbox-client.js
import { request } from "./net.js";

// outbox-core.js is a classic script that must be loaded before this module.
const Outbox = self.Outbox;

export class RejectedError extends Error {
  constructor(status, detail) {
    super(`The server rejected the request (HTTP ${status})`);
    this.name = "RejectedError";
    this.status = status;
    this.detail = detail;
  }
}

const isTransient = (status) => [401, 408, 409, 425, 429].includes(status) || (status >= 500 && status !== 501);

/**
 * Send a write now if possible, otherwise queue it.
 * @param {{id?: string, url: string, method?: string, json: unknown, meta?: object}} options
 */
export async function submit({ id = crypto.randomUUID(), url, method = "POST", json, meta = {} }) {
  const headers = [["Content-Type", "application/json"]];
  const body = JSON.stringify(json);

  if (navigator.onLine) {
    try {
      const response = await request(url, {
        method,
        headers: [...headers, ["Idempotency-Key", `"${id}"`]],
        body,
        credentials: "same-origin",
        redirect: "manual",
        timeoutMs: 10_000, // a write gets a longer budget than a read
      });
      if (response.ok) return { status: "sent", id, response };
      if (response.type !== "opaqueredirect" && !isTransient(response.status)) {
        throw new RejectedError(response.status, await response.text().catch(() => ""));
      }
      // Transient error or expired session: fall through and queue it.
    } catch (error) {
      if (error instanceof RejectedError) throw error;
      // TypeError (offline) or TimeoutError: the server may or may not have the write.
      // Queuing it with the same Idempotency-Key makes the replay safe either way.
    }
  }

  await Outbox.add({ id, url, method, headers, body, meta });
  await scheduleReplay();
  return { status: "queued", id };
}

/** Ask the browser to replay in the background (Chromium); otherwise replay from the page. */
export async function scheduleReplay() {
  const registration = await navigator.serviceWorker?.getRegistration();
  if (registration?.sync) {
    try {
      // Registering an existing tag reuses it: one sync event drains the whole queue.
      await registration.sync.register(Outbox.SYNC_TAG);
      return "background-sync";
    } catch (error) {
      // InvalidStateError: no active worker yet. NotAllowedError: the user
      // disabled background sync for this site. Fall back to page-driven replay.
      console.info("[outbox] background sync unavailable:", error.name);
    }
  }
  if (navigator.onLine) void Outbox.replay({ reason: "page" });
  return "page";
}

navigator.serviceWorker.getRegistration() is used instead of navigator.serviceWorker.ready on purpose: ready never resolves on a page that has no registration (a first visit before registration, or a browser with service workers disabled), which would leave the submit hanging forever.

Replaying in the service worker with Background Sync

The worker loads the same core script and drains the outbox on the sync event. Rejecting the waitUntil() promise tells the browser to retry later:

sw.js (outbox replay)
// Must run during the worker's initial evaluation: after installation,
// importScripts() of a URL that wasn't imported at install time throws a NetworkError.
importScripts("/js/outbox-core.js");

self.addEventListener("sync", (event) => {
  if (event.tag !== self.Outbox.SYNC_TAG) return;
  event.waitUntil(
    (async () => {
      const result = await self.Outbox.replay({ reason: event.lastChance ? "sync-last-chance" : "sync" });
      // "retry": still offline or the server is struggling. Rejecting schedules another
      // attempt, unless this was the last one; page-side triggers take over after that.
      if (result.status === "retry" && !event.lastChance) {
        throw new Error("Outbox not drained; retry later");
      }
      // "auth-required" and "busy" resolve normally: retrying without a new
      // session can't succeed, and another context is already replaying.
    })()
  );
});

Chromium's scheduling is conservative, and the exact numbers are implementation details that can change. In the current Chromium source, the defaults are a maximum of 3 attempts per registration, a first retry delay of 5 minutes multiplied by 3 for each further attempt, and a limit of 3 minutes on each sync event's waitUntil(). event.lastChance is true on the final attempt. The specification also defines when register() rejects: with InvalidStateError when the registration has no active worker, and with NotAllowedError when the user has disabled background sync. The full API, including tags, permissions and the Periodic Background Sync variant, is covered in Background Sync.

Fallback triggers for Safari and Firefox

Without Background Sync, only a running page can replay. Replay at every moment where success is plausible, and let the lock and the idempotency keys absorb the overlap:

js/outbox-triggers.js
import { connectivity } from "./connectivity.js";

const Outbox = self.Outbox;
let timer = 0;

async function replaySoon(reason) {
  if (!navigator.onLine) return;
  const result = await Outbox.replay({ reason }).catch((error) => ({ status: "retry", error }));
  clearTimeout(timer);
  if (result.status === "retry" && document.visibilityState === "visible") {
    // Still pending: try again later while the page stays open. 30 s is enough to
    // notice recovery without hammering a struggling server.
    timer = setTimeout(() => replaySoon("timer"), 30_000);
  }
}

// 1. App start: drain whatever the last session left behind.
replaySoon("startup");

// 2. The OS says a network came back (a hint, so replay is just an attempt).
addEventListener("online", () => replaySoon("online"));

// 3. The connectivity monitor confirmed reachability with a heartbeat.
connectivity.addEventListener("change", ({ detail }) => {
  if (detail.state === "online") replaySoon("reachable");
});

// 4. The user came back to the app (common on mobile, where tabs are frozen in the background).
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "visible") replaySoon("visible");
});

// 5. A sign-in completed in this or another tab (see the authentication section).
new BroadcastChannel("auth").addEventListener("message", ({ data }) => {
  if (data?.type === "signed-in") replaySoon("signed-in");
});

Two more techniques close the gap. Workbox's background sync module, when the sync event isn't available, replays its queue every time the service worker starts. That's a useful trick for your own worker too: any fetch into scope in Safari or Firefox starts the worker, which then drains the outbox with no page code involved. And for data the user would hate to lose, add a beforeunload listener while the outbox has pending entries and Background Sync isn't available. The browser shows its own generic "Leave site?" dialog; custom text is ignored by every current browser.

Queuing plain HTML form posts without JavaScript

A classic <form method="post"> submission is a navigation request with method POST. The worker can intercept it, and when the network fails, store the body in the same outbox and redirect the user to a confirmation page. This keeps progressive enhancement intact: the form works without any page JavaScript, online and offline.

sw.js (offline form posts)
const QUEUED_PAGE = "/queued.html"; // precached: "Saved. We'll send it when you're back online."

self.addEventListener("fetch", (event) => {
  const { request } = event;
  if (request.mode !== "navigate" || request.method !== "POST") return;
  const url = new URL(request.url);
  if (url.origin !== self.location.origin || !url.pathname.startsWith("/forms/")) return;

  // Clone before fetch() consumes the body.
  const copy = request.clone();
  event.respondWith(
    (async () => {
      try {
        return await fetch(request);
      } catch {
        const id = crypto.randomUUID();
        await self.Outbox.add({
          id,
          url: request.url,
          method: "POST",
          // Keep the original Content-Type: multipart bodies carry their boundary in it.
          headers: [["Content-Type", copy.headers.get("Content-Type") ?? "application/x-www-form-urlencoded"]],
          body: await copy.blob(), // Blob preserves binary file uploads
          meta: { kind: "form", path: url.pathname },
        });
        // Background Sync where available; startup and page triggers elsewhere.
        await self.registration.sync?.register(self.Outbox.SYNC_TAG).catch(() => {});
        // 303: the browser follows with a GET, so reloading the result page never re-posts.
        return Response.redirect(`${QUEUED_PAGE}?id=${encodeURIComponent(id)}`, 303);
      }
    })()
  );
});

Register this listener before the general navigation handler, or make the general handler skip non-GET requests, as the fallback routing worker does. Only the first respondWith() call wins, and calling it twice throws InvalidStateError.

Workbox: BackgroundSyncPlugin

With Workbox, workbox-background-sync provides the queue. BackgroundSyncPlugin adds a failed request to a Queue in its fetchDidFail callback. It stores requests in an IndexedDB database named workbox-background-sync, registers sync tags named workbox-background-sync:<queue name>, drops entries older than maxRetentionTime (in minutes, default 7 days), and, when the sync event isn't available or forceSyncFallback is true, replays on every service worker startup:

sw.js (Workbox background sync)
import { BackgroundSyncPlugin } from "workbox-background-sync";
import { registerRoute } from "workbox-routing";
import { NetworkOnly } from "workbox-strategies";

const outbox = new BackgroundSyncPlugin("api-writes", {
  maxRetentionTime: 7 * 24 * 60, // minutes
  async onSync({ queue }) {
    let entry;
    while ((entry = await queue.shiftRequest())) {
      try {
        const response = await fetch(entry.request.clone());
        if (response.status >= 500 || response.status === 429) throw new Error(`HTTP ${response.status}`);
        // 4xx: permanent, and the entry is already shifted off the queue.
        // Tell the pages instead of retrying forever.
        if (!response.ok) new BroadcastChannel("outbox").postMessage({ type: "rejected", status: response.status });
      } catch (error) {
        await queue.unshiftRequest(entry); // put it back at the front, keep the order
        throw error; // reject: the browser schedules a retry
      }
    }
  },
});

registerRoute(({ url }) => url.pathname.startsWith("/api/"), new NetworkOnly({ plugins: [outbox] }), "POST");

The plugin only queues requests that fail at the network level. An HTTP 500 isn't a failure to fetch(), so it reaches the page as a normal response and isn't queued. The same rule applies on replay: the queue's default replayRequests() only puts an entry back when fetch() rejects, so a replay answered with 500 or 429 counts as delivered and the entry is gone. That's why the example supplies its own onSync. It also has no idempotency support of its own: add the key in the page, where the request is created. Finally, the page learns nothing about queued requests unless you tell it, which is why the example posts to the same outbox channel the rest of the UI listens to. Advanced Workbox covers the module's other options.

Search is where offline apps most often fail silently. The search box still works, the request fails, and the user sees "No results", which is a lie. An offline-capable search has three properties: it falls back to local data, it labels the source of its results, and it admits incompleteness ("Showing results from 42 articles saved on this device").

Choosing what to search locally

Local corpus Where it lives Good for Cost
Records the app already stores IndexedDB Notes, messages, tasks, contacts Needs an index you maintain on every write
Saved and visited pages Cache Storage, indexed into IndexedDB when stored Articles, docs, recipes Parse HTML once at save time, not per query
A prebuilt search index A static JSON file, runtime-cached or precached Documentation and static sites Size grows with the corpus; the index can go stale between deploys

Static site generators commonly ship a prebuilt index for client-side search. This site works that way: its search index is cached with stale-while-revalidate, so search keeps working offline once it has been loaded (see This Site Is a PWA). For app data, build an index yourself.

A local index with a multiEntry IndexedDB index

IndexedDB can act as a small inverted index. Store each document with an array of normalized tokens, and create an index with multiEntry: true. IndexedDB then adds one index entry per array element, so a key range on the index finds every document that contains a token, and a [term, term + "￿"] range gives prefix matching for search-as-you-type:

js/local-search.js
const DB_NAME = "app-search";
const STORE = "docs";

// Intl.Segmenter handles languages without spaces (Chinese, Japanese, Thai).
// Chrome 87, Safari 14.1, Firefox 125; a regex split is the fallback.
const segmenter = "Segmenter" in Intl ? new Intl.Segmenter(undefined, { granularity: "word" }) : null;

/** Lowercase, strip diacritics ("Café" -> "cafe"), split into unique words. */
export function tokenize(text) {
  const normalized = text.normalize("NFKD").replace(/\p{M}+/gu, "").toLowerCase();
  const words = segmenter
    ? Array.from(segmenter.segment(normalized), (s) => (s.isWordLike ? s.segment : "")).filter(Boolean)
    : normalized.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
  return [...new Set(words)].filter((word) => word.length > 1 || /\p{Script=Han}/u.test(word));
}

let dbPromise;
function openDB() {
  dbPromise ??= new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, 1);
    request.onupgradeneeded = () => {
      const store = request.result.createObjectStore(STORE, { keyPath: "url" });
      store.createIndex("tokens", "tokens", { multiEntry: true }); // one entry per token
    };
    request.onsuccess = () => {
      const db = request.result;
      // Close when another context deletes or upgrades the database (for example on sign-out).
      db.onversionchange = () => {
        db.close();
        dbPromise = undefined;
      };
      resolve(db);
    };
    request.onerror = () => reject(request.error);
  });
  return dbPromise;
}

const settle = (request) =>
  new Promise((resolve, reject) => {
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });

/** Add or replace a document. Call it when content is saved or visited, not per query. */
export async function indexDocument({ url, title, text, source = "saved" }) {
  const db = await openDB();
  const tx = db.transaction(STORE, "readwrite");
  tx.objectStore(STORE).put({
    url,
    title,
    source,
    snippet: text.replace(/\s+/g, " ").trim().slice(0, 240),
    titleTokens: tokenize(title),
    tokens: tokenize(`${title} ${text}`),
    indexedAt: Date.now(),
  });
  await new Promise((resolve, reject) => {
    tx.oncomplete = resolve;
    tx.onerror = () => reject(tx.error);
  });
}

export async function removeDocument(url) {
  const db = await openDB();
  await settle(db.transaction(STORE, "readwrite").objectStore(STORE).delete(url));
}

/** AND-query with prefix matching on the last term; ranked by title and exact hits. */
export async function searchLocal(query, { limit = 20 } = {}) {
  const terms = tokenize(query);
  if (terms.length === 0) return { results: [], total: 0 };
  const db = await openDB();

  // One read transaction: issue every key lookup synchronously, then await them together.
  const tx = db.transaction(STORE, "readonly");
  const index = tx.objectStore(STORE).index("tokens");
  const lookups = terms.map((term, i) => {
    const isLast = i === terms.length - 1;
    const range = isLast ? IDBKeyRange.bound(term, `${term}￿`) : IDBKeyRange.only(term);
    return settle(index.getAllKeys(range, 5000)); // primary keys (URLs); may repeat per token
  });
  const count = settle(tx.objectStore(STORE).count());
  const [keyLists, total] = await Promise.all([Promise.all(lookups), count]);

  // Intersect: documents must contain every term.
  let matches = new Set(keyLists[0]);
  for (const keys of keyLists.slice(1)) {
    const next = new Set(keys);
    matches = new Set([...matches].filter((url) => next.has(url)));
  }

  const store = db.transaction(STORE, "readonly").objectStore(STORE);
  const docs = await Promise.all([...matches].slice(0, 500).map((url) => settle(store.get(url))));
  const scored = docs
    .filter(Boolean)
    .map((doc) => {
      let score = 0;
      for (const term of terms) {
        if (doc.titleTokens.includes(term)) score += 5;
        else if (doc.titleTokens.some((t) => t.startsWith(term))) score += 3;
        if (doc.tokens.includes(term)) score += 2;
      }
      return { ...doc, score };
    })
    .sort((a, b) => b.score - a.score || b.indexedAt - a.indexedAt);

  return { results: scored.slice(0, limit), total };
}

Index a page when the user saves it for offline, by reading it back from the saved cache and extracting its text once:

js/index-saved-page.js
import { indexDocument } from "./local-search.js";

export async function indexSavedPage(url, cacheName = "app-saved-v1") {
  const response = await caches.match(url, { cacheName });
  if (!response?.headers.get("Content-Type")?.includes("text/html")) return;
  const doc = new DOMParser().parseFromString(await response.text(), "text/html");
  // DOMParser doesn't run scripts or load subresources: parsing cached HTML is safe and offline.
  const main = doc.querySelector("main, article") ?? doc.body;
  main.querySelectorAll("nav, aside, script, style, [hidden]").forEach((el) => el.remove());
  await indexDocument({ url, title: doc.title, text: main.textContent ?? "" });
}

DOMParser exists only in window contexts, so this runs in the page, not the service worker. For a large corpus, move tokenize() and the indexing into a dedicated worker so typing stays responsive.

Server search with a labeled local fallback

The search controller tries the server with a short timeout, and switches to local results when the network fails, times out, or the service worker reports that it answered from cache:

js/search.js
import { request } from "./net.js";
import { searchLocal } from "./local-search.js";

/**
 * @returns {Promise<{source: "server"|"device", results: object[], note?: string}>}
 */
export async function search(query, { signal } = {}) {
  try {
    const response = await request(`/api/search?q=${encodeURIComponent(query)}`, {
      timeoutMs: 4000, // typing users won't wait longer; local results are instant
      signal,
    });
    // A cached answer for a *different* moment's query is worse than an honest local search.
    if (response.ok && !response.headers.has("SW-Cache-Status")) {
      return { source: "server", results: await response.json() };
    }
  } catch (error) {
    if (signal?.aborted) throw error; // superseded by a newer keystroke, not a network problem
  }
  const { results, total } = await searchLocal(query);
  return {
    source: "device",
    results,
    note: `You're seeing results from ${total} ${total === 1 ? "item" : "items"} saved on this device.`,
  };
}

// Usage: cancel the previous search on every keystroke.
let controller;
export function onSearchInput(query, render) {
  controller?.abort();
  controller = new AbortController();
  search(query, { signal: controller.signal })
    .then(render)
    .catch((error) => {
      if (error.name !== "AbortError") console.error(error);
    });
}

Render the note above the results, not in a toast, and announce result counts through a polite live region. Keep one detail in mind when the device is offline for a long time: local results don't include anything created elsewhere since the last sync. If your data model allows it, show the time of the last successful sync next to the note, using the same freshness formatter.

Authentication and session expiry while offline

Offline and authentication interact in ways that surprise teams in production. The core rule: a network error is not a logout. An app that clears its data or redirects to the login page because a token refresh failed while offline destroys the offline experience and, worse, can discard unsent work.

Classifying auth failures correctly

What happened What the page sees Correct response
Offline, session still valid on the server TypeError from fetch() Stay signed in; serve cached data; queue writes
Token refresh failed because offline TypeError during refresh Keep the old session state; retry the refresh when online
Session expired while the app was offline 401, or a redirect to the login page, on the first request after reconnecting Keep cached data readable; pause the outbox; ask the user to sign in again
User signed out on another device or was revoked 401 or 403 Same as above, but for 403 show that access was removed
Captive portal intercepting requests 200 HTML or a cross-origin redirect Treat as offline (see the heartbeat); never as a login failure

Replays from the service worker need credentials, and the worker can't read what the page keeps in memory:

  • Cookie sessions (HttpOnly, Secure, SameSite=Lax or Strict) work automatically. The browser attaches them to same-origin requests from the worker, including outbox replays with credentials: "same-origin". This is the simplest and most secure design for PWAs.
  • Bearer tokens must be available to the worker at replay time. Storing an access token in IndexedDB makes it readable by any script on your origin, so an XSS bug can steal it. If you do it, store only short-lived access tokens, never refresh tokens, and attach the token at replay time rather than storing an Authorization header in each outbox entry. A token captured when the user went offline has usually expired by the time the entry is replayed.

The security trade-offs of token storage and of the worker as a credential holder are covered in Service Worker Security and Authentication & Passkeys.

Pausing the outbox and re-authenticating without losing state

When a replay returns auth-required, keep every entry, show a non-blocking prompt, and resume after sign-in. A sign-in popup (or a second tab) keeps the app's state intact. A full-page redirect loses in-memory state, so persist anything important first:

js/auth-recovery.js
const outboxChannel = new BroadcastChannel("outbox");
const authChannel = new BroadcastChannel("auth");
const prompt = document.querySelector("#session-expired"); // a role="status" region with a button

outboxChannel.addEventListener("message", ({ data }) => {
  if (data?.type !== "auth-required") return;
  const count = data.pending ?? 0;
  prompt.hidden = false; // unhide first: text inserted into a hidden live region may not be announced
  prompt.querySelector("p").textContent =
    count > 0
      ? `Your session has expired. Sign in to send ${count} saved ${count === 1 ? "change" : "changes"}.`
      : "Your session has expired. Sign in to continue.";
});

prompt.querySelector("button").addEventListener("click", () => {
  // Must run in the click handler: popup blockers allow window.open() only with user activation.
  const popup = window.open("/login?mode=popup", "signin", "popup,width=480,height=640");
  if (!popup) {
    // Blocked, or a platform without popups in standalone mode: navigate instead.
    sessionStorage.setItem("return-to", location.pathname + location.search);
    location.assign(`/login?return=${encodeURIComponent(location.pathname)}`);
  }
});

// The login page posts this after a successful sign-in, then closes itself (if it's a popup).
authChannel.addEventListener("message", ({ data }) => {
  if (data?.type !== "signed-in") return;
  prompt.hidden = true;
  self.Outbox?.replay({ reason: "signed-in" }); // outbox-triggers.js also listens for this
});
js/login-complete.js (runs on the page after a successful sign-in)
new BroadcastChannel("auth").postMessage({ type: "signed-in", at: Date.now() });
if (new URLSearchParams(location.search).get("mode") === "popup") {
  window.close(); // only works for windows opened by script
} else {
  location.replace(sessionStorage.getItem("return-to") ?? "/");
}

If a different user signs in, the pending entries belong to someone else. Store the user ID in each outbox entry (the userId field of Outbox.add()), and before resuming, compare it with the new session. Replaying user A's writes under user B's session is a data-integrity and privacy bug.

Signing out and per-user offline data

Offline support means user data sits on the device after the user has walked away. On shared devices that's a privacy problem, so sign-out must delete per-user data, and it must work while offline:

js/sign-out.js
const PER_USER_CACHE_PREFIXES = ["app-api-", "app-saved-", "app-pages-"]; // everything user-specific
const PER_USER_DATABASES = ["app-outbox", "app-search", "app-data"];

export async function signOut({ discardUnsent = false } = {}) {
  const Outbox = self.Outbox;
  const pending = Outbox ? await Outbox.pendingCount() : 0;
  if (pending > 0 && !discardUnsent) {
    // Let the UI ask: "3 changes haven't been sent. Sign out anyway?"
    return { status: "unsent", pending };
  }

  // 1. Tell the server, but don't let an offline (or lie-fi) device block local cleanup.
  await fetch("/api/logout", {
    method: "POST",
    credentials: "same-origin",
    keepalive: true,
    signal: AbortSignal.timeout ? AbortSignal.timeout(3000) : undefined,
  }).catch(() => {});

  // 2. Delete per-user caches. Keep the precache so the app still opens offline.
  const names = await caches.keys();
  await Promise.all(
    names.filter((name) => PER_USER_CACHE_PREFIXES.some((p) => name.startsWith(p))).map((name) => caches.delete(name))
  );

  // 3. Delete per-user databases. Other tabs must close their connections
  //    (onversionchange handlers), otherwise the deletion stays blocked.
  await Promise.all(
    PER_USER_DATABASES.map(
      (name) =>
        new Promise((resolve) => {
          const request = indexedDB.deleteDatabase(name);
          request.onsuccess = request.onerror = () => resolve();
          request.onblocked = () => console.warn(`[sign-out] ${name} deletion blocked by an open tab`);
        })
    )
  );

  // 4. Tell every tab and the worker, so none keeps showing the old user's data.
  new BroadcastChannel("auth").postMessage({ type: "signed-out" });
  location.replace("/login");
  return { status: "signed-out" };
}

The server can also send a Clear-Site-Data header on the logout response (Chrome 61, Firefox 63 and Safari 17 support the "cookies" and "storage" directives). It's blunt: "storage" deletes IndexedDB and local storage and unregisters the service worker, so the app loses offline support until the next online visit. Use it for "sign out and remove all data from this device", not for every sign-out. It also only runs when the logout request reaches the server, so the client-side cleanup above is still needed for offline sign-outs.

Handling lie-fi with timeouts

Lie-fi is the most common bad network, and the one with the worst default behavior. The Fetch Standard defines no overall timeout for fetch(). A request on a connection that is technically up but not delivering packets stays pending until the browser's or the operating system's network stack gives up, and that can take far longer than any user waits. Every network request in an offline-capable app needs an explicit deadline, and every deadline needs a defined consequence.

Timeout budgets by request type

Request Budget When the deadline passes
Navigation with a cached copy 2–4 s Serve the cached page marked stale; let the network response refresh the cache in the background
Navigation without a cached copy No worker timeout Keep waiting (there is nothing better to show); the browser's own loading UI is visible
API read with cached data 1–5 s Show the cached data with a freshness label; refresh when the response arrives
API read without cached data 8–15 s Error state with a Retry button, not an empty list
Write (POST, PUT, PATCH, DELETE) 10–30 s Move it to the outbox with the same idempotency key; never report "failed"
Heartbeat 3–5 s Degraded state, then backoff polling
Large download No total timeout; a stall timeout of 10–20 s without new bytes Abort and offer to resume or retry
Search-as-you-type 2–4 s Local results with a label

Set navigation timeouts from data, not intuition: take the 95th percentile of your healthy time to first byte from real-user monitoring, and put the timeout above it, so that only genuinely degraded connections fall back. The Caching Strategies page discusses the trade-off from the strategy side.

Stall timeouts for large responses

A fixed total timeout is wrong for large downloads: a 50 MB file legitimately takes minutes on a slow link. What you want to detect is a stall, a connection that stops delivering bytes. Wrap the body stream and reset a watchdog on every chunk:

js/fetch-with-stall-timeout.js
/**
 * fetch() that aborts if the headers don't arrive within `headersTimeoutMs`,
 * or if the body stops delivering bytes for `stallTimeoutMs`.
 */
export async function fetchWithStallTimeout(
  input,
  { headersTimeoutMs = 10_000, stallTimeoutMs = 15_000, signal, ...init } = {}
) {
  const controller = new AbortController();
  if (signal?.aborted) controller.abort(signal.reason);
  signal?.addEventListener("abort", () => controller.abort(signal.reason), { once: true });

  const watchdog = (ms, message) => setTimeout(() => controller.abort(new DOMException(message, "TimeoutError")), ms);

  let timer = watchdog(headersTimeoutMs, "No response headers");
  let response;
  try {
    response = await fetch(input, { ...init, signal: controller.signal });
  } finally {
    clearTimeout(timer);
  }
  if (!response.body) return response;

  const reader = response.body.getReader();
  const body = new ReadableStream({
    async pull(streamController) {
      timer = watchdog(stallTimeoutMs, "Download stalled");
      try {
        const { done, value } = await reader.read(); // rejects with the abort reason on stall
        if (done) streamController.close();
        else streamController.enqueue(value);
      } catch (error) {
        streamController.error(error);
      } finally {
        clearTimeout(timer);
      }
    },
    cancel(reason) {
      clearTimeout(timer);
      return reader.cancel(reason);
    },
  });

  // Note: the wrapper Response has no url, redirected or type information of its own.
  return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
}

Aborting the controller after the headers have arrived errors the original body stream, so the pending reader.read() rejects and the wrapper stream errors with the same TimeoutError. Code that consumes the body (await response.blob()) sees the rejection, exactly as with a normal network failure.

Progressive loading feedback

Users tolerate slow responses much better when the interface acknowledges them. A small helper turns one promise into staged feedback:

js/staged-loading.js
/**
 * Show nothing for fast responses, a skeleton for slower ones, and an explanation
 * with a way out for very slow ones.
 */
export async function withStagedFeedback(promise, { onSlow, onVerySlow, slowMs = 400, verySlowMs = 3000 } = {}) {
  const slow = setTimeout(() => onSlow?.(), slowMs);
  const verySlow = setTimeout(() => onVerySlow?.(), verySlowMs);
  try {
    return await promise;
  } finally {
    clearTimeout(slow);
    clearTimeout(verySlow);
  }
}

// Usage:
// const data = await withStagedFeedback(loadOrders(), {
//   onSlow: () => list.setAttribute("aria-busy", "true"),              // skeleton rows via CSS
//   onVerySlow: () => notice.textContent = "Still loading. Your connection seems slow.",
// });

The thresholds matter. Showing a spinner for a 150 ms response makes the app feel slower than showing nothing. After about 3 seconds, say what is happening and offer an action: show cached data, keep waiting, or cancel.

Why a timed-out write must not be reported as failed

Aborting a fetch() on the client doesn't abort the work on the server. If a POST times out, the order may have been placed. Reporting "Failed, please try again" invites the user to create a duplicate. The correct sequence is: show the item as "Sending…", move the request to the outbox with its idempotency key, replay it, and let the server's deduplication return the original result. The user sees one item that eventually becomes "sent", whichever of the two cases actually happened.

Adapting budgets to the connection

In Chromium, the Network Information API gives you hints for scaling budgets and deferring optional work. Keep the defaults sensible for Safari and Firefox, where the API doesn't exist:

js/budgets.js
const MULTIPLIER = { "slow-2g": 3, "2g": 2.5, "3g": 1.5, "4g": 1 };

/** Scale a timeout by the effective connection type (Chromium only; 1x elsewhere). */
export function budget(baseMs) {
  const type = navigator.connection?.effectiveType;
  return Math.round(baseMs * (MULTIPLIER[type] ?? 1));
}

/** Skip optional downloads (warm caches, prefetching, high-resolution images). */
export function shouldSaveData() {
  const connection = navigator.connection;
  return connection?.saveData === true || ["slow-2g", "2g"].includes(connection?.effectiveType);
}

Longer budgets on slow connections avoid falling back to stale data for responses that would have arrived. Remember that effectiveType is computed from recent observations, so it lags behind sudden changes such as entering a tunnel.

Testing offline behavior

Offline UX breaks quietly: nobody on the team works offline, so regressions ship unnoticed. Test at three levels: DevTools emulation for fast iteration, OS-level or device-level conditions for realism, and automated tests for the paths that must never break.

Chrome and Edge DevTools

  • Offline emulation. Choose Offline in the Network panel's throttling menu, or check Offline in Application › Service workers; Chrome's documentation describes the two as equivalent. Page requests fail with net::ERR_INTERNET_DISCONNECTED, and navigator.onLine becomes false. DevTools applies network conditions to the service worker's target as well, so the worker's own fetch() calls fail too, and your fallbacks run.
  • Background Sync under emulation. In the Chromium source, DevTools offline emulation of a service worker target also holds back its pending sync events, and turning emulation off fires them. That makes the full outbox cycle testable in DevTools. The Sync button in Application › Service workers fires a sync event with a tag you type, which is useful for testing the handler directly. Application › Background services › Background sync records sync registrations and dispatches, even while DevTools is closed if you enable recording.
  • Throttling presets and custom profiles. The throttling menu offers presets (Fast 4G, Slow 4G, 3G) and custom profiles with download, upload and latency values (Settings › Throttling). Custom profiles also have packet loss, packet queue length and packet reordering parameters, but the documentation positions those for throttling WebRTC applications. Treat the presets as "slow", not as a faithful lie-fi simulation.
  • "Server down" without going offline. Open the Network request blocking drawer and block a pattern such as */api/*. Requests fail while navigator.onLine stays true, which is exactly the state where your UI should say "can't reach the server" instead of "you're offline".
  • Two checkboxes to watch. Uncheck Bypass for network while testing offline: it sends every request past the service worker, so fallbacks never run. Check Update on reload while iterating on sw.js, and uncheck it before testing the update flow itself.
  • Storage states. Application › Storage › Clear site data simulates eviction (the next load behaves like a first visit). The same pane can also simulate a custom storage quota, which is the easiest way to exercise QuotaExceededError handling.

Browser DevTools covers these panels, and the Firefox and Safari equivalents, in detail.

Firefox and Safari

Firefox's Network Monitor has its own throttling menu, including an Offline preset and presets from GPRS to Wi-Fi with fixed download, upload and latency values. The Mozilla documentation notes that they give "an approximate idea of the user experience". The Application › Service Workers panel lists the origin's workers with Unregister and Start buttons, and about:debugging#/runtime/this-firefox lists every registered worker in the profile. Firefox's Work Offline mode forces navigator.onLine to false regardless of real connectivity, as MDN's compatibility data notes.

For Safari and iOS, test on real hardware. Airplane Mode on an iPhone with Web Inspector attached over USB is the most faithful offline test there is, and it's the only way to observe Home Screen web app behavior. Apple's Network Link Conditioner is available on macOS (in the Additional Tools for Xcode download) and on iOS devices with developer mode enabled (Settings › Developer). It shapes traffic at the OS level, with packet loss, for every app.

Simulating lie-fi and captive portals realistically

Browser throttling adds latency and caps bandwidth per request. Real lie-fi drops and delays packets, stalls TLS handshakes, and times out DNS. To test your timeouts against that, shape traffic below the browser:

  • macOS and iOS: Network Link Conditioner with a profile such as "Very Bad Network" or a custom profile with high packet loss.
  • Linux: the kernel's netem queueing discipline, for example sudo tc qdisc add dev eth0 root netem delay 2000ms 500ms loss 30% (and sudo tc qdisc del dev eth0 root to remove it).
  • Android: the emulator's cellular network settings, or a physical device in a location with poor reception, which is still the best lie-fi generator available.

A captive portal is easy to fake. Make the health endpoint return 200 with an HTML body on a test build, or put a local proxy in front of the app that answers every request with a login page. The connectivity monitor must then report offline, and the banner must not claim that the app is working.

Automated offline tests with Playwright

Playwright can emulate offline mode per browser context with browserContext.setOffline(true). Playwright's service worker support is Chromium-only. Since Playwright 1.57, network requests issued by service workers in Chromium are reported and routed through the BrowserContext (the PLAYWRIGHT_DISABLE_SERVICE_WORKER_NETWORK environment variable opts out). Don't assume the worker's own fetch() calls fail under setOffline() on every version and configuration. Assert on the fallback UI, as the tests below do, so a test fails loudly if the worker still reaches the network. A minimal suite covers the fallback page, the banner and the outbox round trip:

tests/offline.spec.js
import { expect, test } from "@playwright/test";

// Wait until a service worker is active; the next navigation will be controlled.
async function waitForServiceWorker(page) {
  await page.evaluate(async () => {
    await navigator.serviceWorker.ready;
  });
}

test("uncached navigation offline shows the offline page", async ({ page, context }) => {
  await page.goto("/");
  await waitForServiceWorker(page);

  await context.setOffline(true);
  await page.goto("/never-visited");
  await expect(page.getByRole("heading", { name: "We couldn't reach the server" })).toBeVisible();

  // Coming back online reloads the page via the "online" listener.
  await context.setOffline(false);
  await expect(page).toHaveURL(/never-visited/);
});

test("the banner reflects connectivity", async ({ page, context }) => {
  await page.goto("/");
  await context.setOffline(true);
  // CSS locators pierce open shadow roots.
  await expect(page.locator("offline-banner .message")).toHaveText(/You're offline/);
  await context.setOffline(false);
  await expect(page.locator("offline-banner .message")).toHaveText(/back online/i);
});

test("a comment written offline is queued, then sent", async ({ page, context }) => {
  await page.goto("/articles/1");
  await waitForServiceWorker(page);
  await page.reload(); // make sure this page is controlled

  await context.setOffline(true);
  await page.getByLabel("Comment").fill("Written in a tunnel");
  await page.getByRole("button", { name: "Post" }).click();
  const item = page.locator("#comments li", { hasText: "Written in a tunnel" });
  await expect(item).toHaveAttribute("data-state", "queued");

  await context.setOffline(false); // "online" event, sync event, or both: the lock dedupes
  await expect(item).toHaveAttribute("data-state", "sent", { timeout: 15_000 });
});

Keep the assertions on user-visible state (text, roles, data-state), not on implementation details such as cache names. For the connectivity monitor itself, unit tests with a mocked fetch() are faster and can cover captive-portal responses and timeouts deterministically. Automated Testing covers the test setup, including serving the app over localhost (a secure context) and resetting service worker state between tests.

A manual test matrix

Run this matrix before releases that touch the worker, the outbox or the connectivity code:

Scenario How to produce it Expected behavior
First visit while offline Clear site data, go offline, open the app Browser error page (nothing is installed yet); no broken half-state on the next online visit
Repeat visit offline, cached page Visit a page, go offline, reload Cached page with a staleness label; banner visible
Repeat visit offline, uncached page Go offline, open a never-visited URL Offline page with links to available content
Going offline mid-session Toggle offline while using the app Banner appears within a second; actions queue
Lie-fi OS link conditioner with high loss and latency Cached content after the navigation timeout; "slow connection" notice; no infinite spinners
Captive portal Health endpoint returns 200 HTML Treated as offline; no misleading "online" state
Server down, network fine Block */api/* in DevTools "Can't reach the server", not "you're offline"
Session expired while offline Expire the session cookie, then reconnect Cached data stays readable; outbox pauses; sign-in prompt; replay after sign-in
Queue survives restart Queue a write offline, close the browser, reopen online The write is sent (Background Sync or startup replay) exactly once
Storage evicted Clear site data after using the app App behaves like a first visit; no crash on missing caches or databases
Worker update while offline Deploy, then open the app offline Old worker keeps serving; the update installs on the next online visit
Screen reader VoiceOver, TalkBack or NVDA, toggle offline Banner change announced once; queued items have text labels

Common pitfalls

Symptom Cause Fix
Banner says "online" on a hotel Wi-Fi login page navigator.onLine or any 2xx treated as proof of connectivity Heartbeat that accepts only your own 204
Banner never leaves "offline" Heartbeat answered from a cache (HTTP or service worker) cache: "no-store", and let the worker ignore the health path
Offline page appears inside images and iframes Fallback chosen by request.mode, not request.destination Select fallbacks by destination; handle iframes separately
SyntaxError: Unexpected token '<' in API code while offline The worker answered an API request with the offline HTML page Synthetic JSON 503 for fetch() requests
Offline page looks broken Relative asset URLs resolved against the requested path, or uncached web fonts Root-absolute URLs; inline CSS; system fonts
Duplicate orders or comments after reconnecting Replays without idempotency keys Client-generated IDs sent as Idempotency-Key, deduplicated by the server
Queued writes silently disappear Workbox's default replay treats 4xx/5xx responses as delivered Custom onSync, or your own outbox with response classification
Writes only sync in Chrome Background Sync used without page-side fallback triggers Replay on startup, online, visibilitychange and sign-in
Users logged out by a subway ride Network errors during token refresh treated as authentication failures Distinguish TypeError from 401; keep the session on network errors
Replayed writes land in the wrong account Outbox entries not tied to a user ID Store userId per entry; compare before replaying
Old user's data visible after sign-out Per-user caches and databases not deleted Client-side cleanup that works offline, plus Clear-Site-Data when appropriate
Spinner forever on weak signal No timeout on fetch() Deadlines on every request, stall timeouts for large downloads
Screen reader announces nothing Text inserted into a live region that was hidden or created at the same time Keep a persistent, visible-to-AT live region in the DOM from page load
Disabled button with no explanation disabled removes the control from the tab order aria-disabled="true" plus a described reason

Browser support

Support data as of September 2026. Check MDN and caniuse.com for live data.

Feature Chrome Edge Firefox Safari Safari on iOS
navigator.onLine, online/offline on Window ✅ ✅ ✅ ✅ ✅
online/offline events on WorkerGlobalScope ❌ ❌ ✅ 29 ✅ 8 ✅ 8
Network Information API (navigator.connection) ✅ 61 ✅ 79 ❌ ❌ ❌
connection.saveData ✅ 65 ✅ 79 ❌ ❌ ❌
Background Sync (registration.sync) ✅ 49 ✅ 79 ❌ ❌ ❌
BroadcastChannel ✅ 54 ✅ 79 ✅ 38 ✅ 15.4 ✅ 15.4
Web Locks API (window and workers) ✅ 69 ✅ 79 ✅ 96 ✅ 15.4 ✅ 15.4
AbortSignal.timeout() with TimeoutError ✅ 124 ⚠️ ✅ 124 ⚠️ ✅ 100 ✅ 16 ✅ 16
AbortSignal.any() ✅ 116 ✅ 116 ✅ 124 ✅ 17.4 ✅ 17.4
crypto.randomUUID() ✅ 92 ✅ 92 ✅ 95 ✅ 15.4 ✅ 15.4
IndexedDB durability option ✅ 83 ✅ 83 ✅ 126 ✅ 15 ✅ 15
Intl.RelativeTimeFormat ✅ 71 ✅ 79 ✅ 65 ✅ 14 ✅ 14
Intl.Segmenter ✅ 87 ✅ 87 ✅ 125 ✅ 14.1 ✅ 14.5
StorageManager.persist() ✅ 55 ✅ 79 ✅ 57 ✅ 15.2 ✅ 15.2
Clear-Site-Data ("cookies", "storage") ✅ 61 ✅ 79 ✅ 63 ✅ 17 ✅ 17
Response.json() static method ✅ 105 ✅ 105 ✅ 115 ✅ 17 ✅ 17

⚠️ Chrome and Edge 103 to 123 support AbortSignal.timeout(), but abort with an AbortError instead of a TimeoutError.

MDN's data notes further differences for navigator.onLine: Chrome on Linux always reports true, and Firefox's Work Offline mode forces false. Firefox briefly shipped the Network Information API (desktop Firefox 31 only; Firefox for Android until version 99), and it was removed. Everything on this page degrades gracefully where a feature is missing: the monitor falls back to heartbeats, the outbox to page-driven replay, and the tokenizer to a regular expression.

Debugging offline UX

"Why did the user see the browser's error page?" Find the request in the Network panel with offline emulation on. If the Size column says (ServiceWorker) for other requests but not for this navigation, the worker didn't call respondWith() for it: check scope, the method filter, and whether a denylist or an early return skipped it. If the worker did respond, check the worker's console for a rejected promise inside respondWith().

"Why is the banner wrong?" Log the monitor's change events with their reason field (offline-event, request-timeout, poll, and so on). A banner stuck on "offline" while requests succeed usually means the heartbeat is failing on its own: a 404 because the endpoint isn't deployed, a CORS or CSP connect-src block, or a cache answering it.

"Why didn't the outbox replay?" In Chromium, enable recording in Application › Background services › Background sync and reproduce: it shows registrations, dispatches and whether the event's promise was rejected. Check the IndexedDB viewer under Application › Storage › IndexedDB › app-outbox for entries stuck in pending with a lastError. A busy result from replay() means another context holds the Web Lock. Application › Storage doesn't list held locks, but await navigator.locks.query() in the console shows both held and pending ones.

"Why does the freshness label say 'now' for old data?" The response probably lacks the SW-Fetched-At stamp (it was cached before you added stamping, or cached by another code path), and the fallback Date header was generated by a CDN at delivery time. Inspect the cached response's headers in Application › Cache storage.

Further reading

On this site

External references