This guide sits alongside the caching and replay machinery described in PWA Offline Recovery & Service Worker Resilience: before a request can be replayed, the app has to know — honestly — whether it is connected at all.

The problem: navigator.onLine is not a connectivity API #

Every offline feature starts with the same one-liner, and every one of them eventually produces the same bug report: “the app says I’m online but nothing works”. navigator.onLine answers a narrow question — does this device have a network interface that could route packets? — and teams read it as a much broader one.

It returns true in all of these situations:

  • The device is on a café Wi-Fi captive portal that intercepts every request until the user accepts terms.
  • A corporate VPN dropped, so the API host no longer resolves.
  • The origin’s API is down while static assets still serve from a CDN.
  • The connection is technically alive but so degraded that every request times out.

And it can be stale: the event fires on interface change, not on reachability change, so a device that has switched from Wi-Fi to a dead cellular connection may not fire anything at all. Building an offline banner on this signal produces a UI that confidently contradicts what the user is seeing, which is worse than having no banner.

The fix is not complicated — probe a tiny endpoint and debounce the result — but the UX decisions around it are where most implementations go wrong.

Prerequisites #


Core implementation: a probing reachability monitor #

The monitor keeps one piece of state — online, offline, or checking — and only transitions after consecutive agreeing probes. Consumers subscribe; nothing else in the app reads navigator.onLine directly.

// reachability.ts
export type Reachability = 'online' | 'offline' | 'checking';

interface Options {
  probeUrl?: string;
  timeoutMs?: number;
  /** Consecutive agreeing probes required before the state flips. */
  confirmations?: number;
}

type Listener = (state: Reachability, detail: { since: number }) => void;

export function createMonitor({
  probeUrl = '/_up',
  timeoutMs = 3000,
  confirmations = 2,
}: Options = {}) {
  let state: Reachability = navigator.onLine ? 'online' : 'offline';
  let since = Date.now();
  let agreeing = 0;
  let backoff = 2000;
  let timer: ReturnType<typeof setTimeout> | undefined;
  const listeners = new Set<Listener>();

  async function probe(): Promise<boolean> {
    const ctl = new AbortController();
    const t = setTimeout(() => ctl.abort(), timeoutMs);
    try {
      // no-store defeats both the HTTP cache and a stale service-worker match,
      // so a 200 here really means the network answered.
      const res = await fetch(probeUrl, {
        method: 'GET',
        cache: 'no-store',
        signal: ctl.signal,
        headers: { 'X-Probe': '1' },
      });
      // A captive portal typically returns 200 with HTML: check the contract.
      return res.status === 204 || res.headers.get('X-Up') === '1';
    } catch {
      return false;
    } finally {
      clearTimeout(t);
    }
  }

  function settle(next: Reachability) {
    if (next === state) { agreeing = 0; return; }
    agreeing += 1;
    if (agreeing < confirmations) return;
    agreeing = 0;
    state = next;
    since = Date.now();
    backoff = 2000;
    listeners.forEach((fn) => fn(state, { since }));
  }

  async function check() {
    const reachable = await probe();
    settle(reachable ? 'online' : 'offline');
    schedule();
  }

  function schedule() {
    clearTimeout(timer);
    // Poll only while offline, with backoff; online state waits for events.
    if (state === 'offline') {
      backoff = Math.min(backoff * 1.6, 30_000);
      timer = setTimeout(() => void check(), backoff);
    }
  }

  window.addEventListener('online', () => void check());
  window.addEventListener('offline', () => settle('offline'));
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible') void check();
  });

  return {
    get state() { return state; },
    get since() { return since; },
    /** Call after any request failure: the failure itself is evidence. */
    reportFailure() { void check(); },
    subscribe(fn: Listener) { listeners.add(fn); return () => listeners.delete(fn); },
  };
}

Two design choices carry most of the value. Probes stop entirely while the state is online — the app learns about failures from real requests via reportFailure(), which costs nothing. And the probe validates a contract (204, or an X-Up header) rather than merely a 200, which is what distinguishes your server from a captive portal’s login page.

Reachability state machine Three states drawn left to right: online, checking, and offline. A request failure or a browser online event moves online into checking. Two agreeing failing probes move checking into offline. Two agreeing succeeding probes move checking back to online. While offline a backing-off timer re-enters checking. A browser offline event moves directly to offline. online no polling checking probe with timeout offline backing-off retry request failed 2× probe ok 2× probe fail timer / visible browser 'offline' event — trusted immediately (a false negative is unlikely) navigator.onLine only ever schedules a probe; it never sets the state to online by itself probe contract: HTTP 204, or 200 with X-Up: 1 — a captive portal's HTML 200 fails both

Architecture note: asymmetric trust #

The state machine deliberately trusts the browser’s offline event immediately but never trusts its online event. That asymmetry is the whole design.

A false “offline” is nearly impossible: if the OS reports no interface, nothing is going anywhere. A false “online” is routine — the interface came up, the portal has not been accepted, DNS has not settled, or the API is still restarting. Flipping the banner to “back online” the instant the event fires produces the worst possible UX: the app declares recovery, immediately retries a queue, every retry fails, and the banner flips back. Requiring two agreeing probes costs a second or two of delay and eliminates the flicker entirely.

Offline UX: degrade the surface, not the intent #

Once the state is trustworthy, the interface decisions are about preserving the user’s ability to act.

Pattern Do Avoid
Global indicator A persistent, polite live region outside the router outlet A toast that disappears before the user reads it
Buttons Accept the action, queue it, badge it “pending” Disabling every control the moment the banner appears
Reads Serve cached data with an explicit “as of HH:MM” stamp Blank states that imply the data does not exist
Writes that cannot queue Explain why now is not possible and what to do A generic “something went wrong”
Reconnect Drain the queue with jitter; report what synced Silent background sync the user never sees

The banner itself should say what is true and nothing more. “You’re offline — 3 changes will sync when you’re back” is a complete statement of the situation and the plan. “Connection lost” is neither.

// OfflineBanner.tsx
export function OfflineBanner({ state, queued }: { state: Reachability; queued: number }) {
  // aria-live="polite" so the announcement waits for a natural pause, and the
  // region is always present in the DOM so its updates are announced at all.
  return (
    <div className="conn-banner" role="status" aria-live="polite" data-state={state}>
      {state === 'offline' && (
        <>
          <strong>You're offline.</strong>{' '}
          {queued > 0
            ? `${queued} change${queued === 1 ? '' : 's'} will sync when you're back.`
            : 'Saved pages are still available.'}
        </>
      )}
      {state === 'checking' && <>Checking your connection…</>}
      {state === 'online' && queued > 0 && <>Syncing {queued} pending change…</>}
    </div>
  );
}

The region must exist in the DOM at all times, not be conditionally mounted, or assistive technology will not announce the first transition — the same live-region rule covered in Building Accessible Error Fallback UI with ARIA Live Regions.

Reconnect without a thundering herd #

When a network comes back, every open tab of every affected user discovers it within a second or two of each other. If they all immediately drain their queues and refetch their views, your API absorbs the entire outage’s demand in one spike — frequently taking the service down again just as it recovers.

// reconnect.ts
const BASE_SPREAD_MS = 4000;

export function onReconnect(monitor: ReturnType<typeof createMonitor>, drain: () => Promise<void>) {
  let scheduled: ReturnType<typeof setTimeout> | undefined;

  return monitor.subscribe((state) => {
    if (state !== 'online') { clearTimeout(scheduled); return; }

    // Jitter proportional to how long we were down: longer outage, wider spread.
    const downMs = Date.now() - monitor.since;
    const spread = Math.min(BASE_SPREAD_MS + downMs / 20, 30_000);
    const delay = Math.random() * spread;

    scheduled = setTimeout(() => {
      void drain().catch(() => monitor.reportFailure());
    }, delay);
  });
}

Scaling the jitter window with outage duration is the detail that matters: a two-second blip barely needs spreading, while a twenty-minute outage has accumulated an audience large enough to need a full spread. Pair this with the cache-warming approach on reconnect so refetching is prioritised rather than indiscriminate.

Retry volume at reconnect, with and without jitter Two small charts sharing a dashed capacity line. The left chart, labelled no jitter, shows a single narrow bar rising well above the capacity line. The right chart, labelled jittered, shows six short bars of equal total area, all below the capacity line. no jitter jittered over a spread window capacity capacity every client retries in the same second same total requests, spread over the window Banner copy that states the situation and the plan Two banner mock-ups. The vague one reads connection lost with a note that it tells the user nothing actionable. The specific one reads you are offline with three changes queued and states they will sync automatically. "Connection lost" is my work saved? can I keep going? nothing here answers either question "You're offline." 3 changes will sync when you're back. states the situation and the plan the count comes straight from the queue the same region also announces recovery: "Syncing 3 pending changes…"

Deep dives in this guide #

Writing the copy before writing the logic #

Connectivity states are one of the few places where interface copy is genuinely part of the engineering design, because the words determine what the state machine has to be able to distinguish. “You’re offline” and “We can’t reach our servers” are different claims, and only the second is honest during your own outage — so if you intend to say the second, the monitor has to be able to tell the two apart.

Writing the copy first tends to produce a smaller, better state machine. Four states usually cover it: connected, degraded but usable with stale data, offline with work queued, and a service problem where the device is online but the backend is not. Each needs one sentence and, where relevant, a count of pending changes. Anything beyond that is a distinction the user cannot act on.

The second reason to start with copy is that it forces the accessibility question early. Every one of those sentences must be announced by the live region rather than communicated only by colour or an icon, and phrasing that reads naturally when spoken is different from phrasing designed to fit a badge. Getting that right at the start avoids a redesign after the first accessibility review.

Finally, copy is where you promise something the system must then deliver. “3 changes will sync when you’re back” is a commitment that the queue is durable, ordered and idempotent. If the implementation cannot honour that sentence, the sentence — not the user’s expectations — is what has to change.

Frequently asked questions #

Why does navigator.onLine say true when nothing loads?

It reports whether the device has a network interface that could route traffic, not whether your server is reachable. Captive portals, expired VPNs, DNS failures and origin outages all leave it true. Treat it as a fast negative signal and confirm positives with a probe.

How often should the app probe for connectivity?

Not on a fixed fast interval. Probe on the online/offline events, on visibility change, immediately after a failed request, and otherwise on a backing-off schedule while offline. Constant polling drains battery and creates the load spike you are trying to avoid.

Should buttons be disabled while offline?

Rarely. Disabling controls removes the user’s ability to express intent. Accept the action, persist it to a queue, show it as pending, and reconcile on reconnect. Reserve disabling for operations that genuinely cannot be queued.

Where should the offline banner live in the DOM?

In a persistent region outside the router outlet, with a polite live region. Rendering it inside the routed view means a navigation can unmount the only indication that the app is offline.

How do I test offline behaviour reliably?

Toggle the browser context offline in an end-to-end run rather than mocking navigator.onLine — only the real toggle exercises fetch failures and service-worker fallbacks together. Add one test that goes offline mid-flow, queues an action, returns online, and asserts the queue drained exactly once.

Testing the states you cannot easily reach #

Three of the four connectivity states are awkward to reproduce by hand, which is why they are usually shipped untested. A captive portal can be simulated by pointing the probe URL at a route that returns 200 with HTML; a backend outage by returning 503 from the probe while assets still serve; a degraded-but-alive connection by throttling to a profile with a multi-second round trip.

Building those three fixtures once, behind a development-only switch, turns “we think the banner is right” into something a reviewer can check in a minute — and it is the only practical way to keep the copy honest as the states multiply.