Why navigator.onLine is not enough (and how we got here)

Why navigator.onLine is not enough (and how we got here)

A practical guide to understanding why navigator.onLine alone is unreliable and how to build a robust, user-friendly connectivity check that combines browser signals with real network reachability tests.

Back in the early days of HTML5, navigator.onLine was introduced as a simple way to ask: is the browser online or offline? In theory, it’s a boolean reflecting whether the user agent is connected to a network. Over time, it became a convenient hook: you can listen to window.addEventListener('online', …) and offline events to detect when connectivity changes.

However, as browser vendors and operating systems evolved, the implementation of navigator.onLine diverged in subtle but crucial ways. Some platforms interpret “online” merely as “network interface is up” (e.g. you have a LAN or virtual adapter) even if there is no path to the internet. Others test connectivity by pinging a known host (e.g. Windows sometimes probes Microsoft servers) — but such probes may be blocked by firewalls, proxies, or VPNs.

Thus, navigator.onLine === true does not guarantee that your app can reach your server or any desired endpoint. Conversely, navigator.onLine === false is a stronger signal that the machine isn’t even network-connected (though corner cases may exist). In practice, using navigator.onLine alone leads to false positives (indicating “online” though APIs fail) and thus degraded UX.

To work around this, engineers have adopted a hybrid approach: use navigator.onLine as a cheap early check, then perform a lightweight “reachability probe” (e.g. via fetch) to confirm that your backend or a known resource is reachable.

Below I propose a modern, resilient strategy, then walk through sample implementations.

Strategy: hybrid reachability + fallback, with timeouts

Here’s a conceptual flow for determining “actually online” (i.e. your app can meaningfully communicate with its services):

flowchart TD
  A{navigator.onLine?} -->|false| B[Declare offline]
  A -->|true| C[Perform reachability probe]
  C -->|success| D[Declare online]
  C -->|failure| E[Declare offline]

Key design principles:

  • Fail fast with timeout: If a probe takes too long (e.g. > 1 s), treat it as unreachable rather than waiting indefinitely.
  • Use a cheap HTTP request: Use HEAD or a static “ping” endpoint rather than fetching large payloads.
  • Throttle checks: Don’t probe too frequently (every second) in normal operation; back off after failures.
  • Fallback modes: If reachability fails repeatedly, optionally allow degraded mode (read-only, cached responses) rather than simply showing “offline”.
  • Listen to browser events: When the browser fires online or offline events, trigger a fresh check.

This approach gives you a more trustworthy “connected or not” indicator before making real API calls.

Sample implementation in plain JavaScript

Here’s a standalone utility you can drop into your app:

async function canReach(url, timeout = 1000) {
  // Quick check: if browser thinks it’s offline, bail early
  if (!navigator.onLine) {
    return false;
  }

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeout);

  try {
    // Use HEAD so we don’t download body payload
    const resp = await fetch(url, {
      method: 'HEAD',
      signal: controller.signal,
      cache: 'no-cache',
    });
    return resp.ok;
  } catch (err) {
    return false;
  } finally {
    clearTimeout(timer);
  }
}

// A wrapper that caches last result and avoids over-probing
class OnlineChecker {
  constructor(pingUrl = '/favicon.ico', timeout = 1000, probeInterval = 30000) {
    this.pingUrl = pingUrl;
    this.timeout = timeout;
    this.probeInterval = probeInterval;
    this.lastResult = null;
    this.probeTimer = null;

    window.addEventListener('online', () => this.triggerProbe());
    window.addEventListener('offline', () => {
      this.lastResult = false;
      this.clearProbeTimer();
    });
  }

  async triggerProbe() {
    const ok = await canReach(this.pingUrl, this.timeout);
    this.lastResult = ok;
    // schedule next
    this.clearProbeTimer();
    this.probeTimer = setTimeout(() => this.triggerProbe(), this.probeInterval);
  }

  clearProbeTimer() {
    if (this.probeTimer) {
      clearTimeout(this.probeTimer);
      this.probeTimer = null;
    }
  }

  start() {
    this.triggerProbe();
  }

  stop() {
    this.clearProbeTimer();
  }

  isOnline() {
    return this.lastResult;
  }
}

// Usage:
const checker = new OnlineChecker('/ping', 800, 15000);
checker.start();

// Later, e.g. before making API call:
if (checker.isOnline()) {
  // proceed
} else {
  // show offline UI or fallback
}

Notes:

  • I used /ping (you could use any lightweight endpoint your server supports).
  • The OnlineChecker listens for browser online/offline events so it re-checks when connectivity changes.
  • The probeInterval ensures you don’t hammer your server continuously.
  • You can adapt or extend this to exponential backoff (e.g. double the interval after failures) or to stop probing after a threshold.

React example (hooks + context)

Below is a React-centric version using hooks and context, so you can access isOnline throughout your component tree.

import React, { createContext, useContext, useEffect, useState } from 'react';

async function pingUrl(url, timeout = 1000) {
  if (!navigator.onLine) return false;
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeout);
  try {
    const resp = await fetch(url, { method: 'HEAD', signal: controller.signal, cache: 'no-cache' });
    return resp.ok;
  } catch {
    return false;
  } finally {
    clearTimeout(timer);
  }
}

const OnlineContext = createContext({ isOnline: true });

export function OnlineProvider({ ping = '/ping', timeout = 1000, intervalMs = 15000, children }) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    let intervalId = null;

    const check = async () => {
      const ok = await pingUrl(ping, timeout);
      setIsOnline(ok);
    };

    check();

    intervalId = setInterval(check, intervalMs);

    window.addEventListener('online', check);
    window.addEventListener('offline', () => setIsOnline(false));

    return () => {
      if (intervalId) clearInterval(intervalId);
      window.removeEventListener('online', check);
      window.removeEventListener('offline', () => setIsOnline(false));
    };
  }, [ping, timeout, intervalMs]);

  return <OnlineContext.Provider value={{ isOnline }}>{children}</OnlineContext.Provider>;
}

// Hook to consume
export function useOnline() {
  return useContext(OnlineContext).isOnline;
}

Then in your app:

function App() {
  const isOnline = useOnline();

  if (isOnline === null) {
    return <div>Checking connectivity…</div>;
  }
  if (!isOnline) {
    return <div>No internet connection.</div>;
  }
  return <div>Welcome! (You are online.)</div>;
}

This pattern ensures React components re-render when reachability changes, and you get a global “online state.”

Caveats, trade-offs, and best practices

  • Some corporate networks block HEAD or static ping endpoints, or intercept them (e.g. captive portals). Pick a ping URL that’s likely to bypass such restrictions (e.g. one you control).
  • Don’t trust continuous “failure” = offline forever. Try periodic retries (e.g. backoff logic).
  • If your app must always work offline-first, consider service workers + cached responses so some core features operate without connectivity.
  • On mobile, connectivity may fluctuate rapidly; use heuristics (e.g. require 2 successive failures) before declaring offline.
  • Keep the latency of your reachability probe low. A 1 s timeout is reasonable, though network conditions may vary.

Summary & next steps

You now understand why navigator.onLine === true is too weak a signal: it may merely reflect that a network interface is up, not that true connectivity exists. The hybrid method I presented (navigator hint + reachability probe + timeout + event listeners) offers a more robust indicator.

Next, you might:

  1. Adapt the code to TypeScript for stronger typing.
  2. Add exponential backoff or “circuit breaker” logic after repeated failures.
  3. Integrate with your API client so all network calls pass through a gate guarded by connectivity.
  4. Add logging/telemetry for diagnostics (e.g. how often probes fail in real users).

Was this helpful?

Thanks for your feedback!

Leave a comment

Your email address will not be published. Required fields are marked *