This page implements the monitor described in Connectivity Detection & Offline UX Patterns, the piece every offline feature depends on being honest.
The exact problem #
The app shows “You’re online” because navigator.onLine is true. Every request is failing. The user is on hotel Wi-Fi behind a captive portal that intercepts all traffic and returns its own login page with a 200 status.
Variants of this are routine: a VPN dropped so the API host no longer resolves; the origin’s API is down while the CDN still serves assets; the connection is technically alive but every request times out. In all of them the device has a network interface — which is the only thing navigator.onLine reports.
What an application actually needs to know is narrower and more useful: can I reach my own backend right now?
The endpoint #
The probe is only as good as what it asks for. A route that returns a body, or that can be cached, or that a portal can imitate, will lie to you.
// server: the smallest possible truthful answer
app.get('/_up', (_req, res) => {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
res.set('X-Up', '1'); // the contract a captive portal cannot forge
res.status(204).end(); // no body: nothing to parse, nothing to cache
});
Serve it from the same host as your API, not from a static CDN. If the CDN answers while the API is down, a probe against the CDN reports “online” and every subsequent request still fails — which is the exact failure this whole exercise exists to prevent.
Zero-to-working probe #
// probe.ts
export interface ProbeResult {
reachable: boolean;
ms: number;
reason?: 'timeout' | 'network' | 'contract' | 'status';
}
export async function probe(url = '/_up', timeoutMs = 3000): Promise<ProbeResult> {
const started = performance.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: 'GET',
cache: 'no-store', // bypass the HTTP cache
signal: controller.signal,
credentials: 'omit', // no cookies: keep it cheap and neutral
headers: { 'X-Probe': '1' }, // lets the SW skip its cache for this
});
const ms = performance.now() - started;
if (res.status === 204 || res.headers.get('X-Up') === '1') return { reachable: true, ms };
// A 200 with HTML is the classic captive-portal signature.
return { reachable: false, ms, reason: res.ok ? 'contract' : 'status' };
} catch (err) {
const ms = performance.now() - started;
const timedOut = err instanceof DOMException && err.name === 'AbortError';
return { reachable: false, ms, reason: timedOut ? 'timeout' : 'network' };
} finally {
clearTimeout(timer);
}
}
Debouncing and scheduling #
// monitor.ts
type State = 'online' | 'offline';
export function createMonitor(onChange: (s: State, detail: { reason?: string }) => void) {
let state: State = navigator.onLine ? 'online' : 'offline';
let agreeing = 0;
let delay = 2000;
let timer: ReturnType<typeof setTimeout> | undefined;
async function check(): Promise<void> {
const result = await probe();
const next: State = result.reachable ? 'online' : 'offline';
if (next === state) {
agreeing = 0;
} else if (++agreeing >= 2) { // two agreeing probes before flipping
agreeing = 0;
state = next;
delay = 2000;
onChange(state, { reason: result.reason });
}
schedule();
}
function schedule(): void {
clearTimeout(timer);
if (document.visibilityState === 'hidden') return; // never poll a hidden tab
if (state === 'online') return; // no polling while healthy
delay = Math.min(delay * 1.6, 30_000);
timer = setTimeout(() => void check(), delay);
}
window.addEventListener('offline', () => { // trusted immediately
if (state === 'offline') return;
state = 'offline';
onChange(state, { reason: 'network' });
schedule();
});
window.addEventListener('online', () => void check()); // never trusted alone
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') void check();
else clearTimeout(timer);
});
return {
get state() { return state; },
reportFailure: () => void check(), // call after any request failure
stop: () => clearTimeout(timer),
};
}
Three scheduling rules carry the cost profile. No polling while online — real request failures are a better signal and cost nothing. No polling while hidden — a backgrounded tab draining battery is how features get uninstalled. And backoff while offline, because a device in a tunnel does not benefit from being asked every two seconds.
| Trigger | Action | Why |
|---|---|---|
offline event |
Flip to offline immediately | A false negative is essentially impossible |
online event |
Probe; do not trust | The interface came up; the portal may not have |
| Any request failure | Probe once | The failure is free evidence |
| Tab becomes visible | Probe once | State may have changed while hidden |
| Tab hidden | Cancel the timer | Battery |
| Still offline | Probe with growing backoff | Recovery detection without a busy loop |
Feeding the rest of the app #
// wire it up once
const monitor = createMonitor((state, { reason }) => {
store.setConnectivity(state, reason);
if (state === 'online') void drainQueue(); // see the offline queue page
});
// Every fetch failure becomes evidence, without a probe of its own.
export async function apiFetch(input: RequestInfo, init?: RequestInit) {
try {
return await fetch(input, init);
} catch (err) {
monitor.reportFailure();
throw err;
}
}
Routing every request through one wrapper is what keeps the monitor accurate without polling. It also gives you the hook for the queueing behaviour described in Queueing User Actions Behind an Offline Banner Without Data Loss, and pairs with the replay queue in Background Sync & Request Replay Queues.
Verification #
- Simulate a captive portal. Point the probe at a URL that returns 200 with HTML; the monitor must report offline with
reason: 'contract'. - Kill the API, keep the CDN. Confirm the probe fails while static assets still load — the case a third-party probe would get wrong.
- Toggle the OS network. Confirm the
offlineevent flips state instantly, while coming back requires two agreeing probes. - Hide the tab while offline. Confirm no probe requests are issued until it becomes visible again.
- Check the service worker. With a cache-first worker installed, confirm the probe still hits the network — if it is served from cache, the exclusion in the worker’s fetch handler is missing.
Frequently asked questions #
Can I probe a third-party URL instead of my own?
You can reach it, but it answers the wrong question and will report success during your own outage. Probe your own origin, ideally the API host.
Does the service worker interfere with the probe?
It can — a cache-first handler will serve a cached 204 and make an offline device look online. Exclude the probe path or mark the request for bypass.
How expensive is this in practice?
Nearly free while online, since there is no polling. While offline, a two-to-thirty-second backoff costs a handful of failed connections per minute and stops when the tab is hidden.
Related #
- Connectivity Detection & Offline UX Patterns — the UX built on this signal
- Queueing User Actions Behind an Offline Banner Without Data Loss — what to do with the offline state
- Cache Warming Strategies After Network Drops in PWAs — what to fetch first once the probe says yes