ESC

Type to search the knowledge base.

Offline Status Indicator

Machine-coding brief for online/offline UI — navigator.onLine, events, banners, queue hooks, and a11y live regions.

intermediate4 min read
  • machine-coding
  • interview
  • react
  • offline

Problem statement

Build an offline status indicator: detect browser online/offline, show a banner or badge, and optionally gate network actions. Interviewers score event subscription cleanup, honest limitations of navigator.onLine, and accessible announcements — not a full offline sync engine.

Requirements

Must have

  • Reflect online vs offline status in the UI
  • Subscribe to window online / offline events
  • Initialize from navigator.onLine
  • Accessible announcement when status changes
  • Cleanup listeners on unmount

Should have

  • Dismissible “Back online” toast that auto-hides
  • useOnlineStatus hook reusable across app
  • Optional heartbeat fetch to detect “captive portal / lie-fi”

Nice to have

  • Queue failed mutations while offline (interface only)
  • Service worker integration note
  • Last changed timestamp

Planning (5 minutes out loud)

  1. navigator.onLine is imperfect — true doesn’t mean API works
  2. Hook first — UI is a thin consumer
  3. Live region for status changes
  4. MVP — badge + banner on offline; then toast on reconnection
  5. Don’t block entire app unless product requires

Architecture

useOnlineStatus()
OnlineBanner / OfflineBadge
optional: createHeartbeatMonitor()

API

type OnlineStatus = {
  online: boolean;
  since: number; // timestamp of last change
};

function useOnlineStatus(): OnlineStatus;

Implementation sketch

Hook

function useOnlineStatus(): OnlineStatus {
  const [online, setOnline] = useState(
    typeof navigator !== "undefined" ? navigator.onLine : true
  );
  const [since, setSince] = useState(() => Date.now());

  useEffect(() => {
    function goOnline() {
      setOnline(true);
      setSince(Date.now());
    }
    function goOffline() {
      setOnline(false);
      setSince(Date.now());
    }
    window.addEventListener("online", goOnline);
    window.addEventListener("offline", goOffline);
    return () => {
      window.removeEventListener("online", goOnline);
      window.removeEventListener("offline", goOffline);
    };
  }, []);

  return { online, since };
}
function ConnectivityBanner() {
  const { online } = useOnlineStatus();
  const [showBack, setShowBack] = useState(false);
  const wasOffline = useRef(false);

  useEffect(() => {
    if (!online) {
      wasOffline.current = true;
      setShowBack(false);
      return;
    }
    if (wasOffline.current) {
      setShowBack(true);
      const t = window.setTimeout(() => setShowBack(false), 3000);
      return () => window.clearTimeout(t);
    }
  }, [online]);

  if (!online) {
    return (
      <div className="banner offline" role="status" aria-live="assertive">
        You are offline. Changes may not be saved.
      </div>
    );
  }

  if (showBack) {
    return (
      <div className="banner online" role="status" aria-live="polite">
        Back online.
      </div>
    );
  }

  return null;
}

Optional heartbeat (lie-fi)

function useReachability(url = "/api/health", intervalMs = 15000) {
  const [reachable, setReachable] = useState(true);

  useEffect(() => {
    let cancelled = false;
    async function tick() {
      try {
        const res = await fetch(url, {
          method: "HEAD",
          cache: "no-store",
        });
        if (!cancelled) setReachable(res.ok);
      } catch {
        if (!cancelled) setReachable(false);
      }
    }
    tick();
    const id = window.setInterval(tick, intervalMs);
    return () => {
      cancelled = true;
      window.clearInterval(id);
    };
  }, [url, intervalMs]);

  return reachable;
}

Combine: show offline if !navigatorOnline || !reachable with careful UX to avoid flapping (require 2 failed heartbeats).

Gating actions

function SaveButton({ onSave }: { onSave: () => void }) {
  const { online } = useOnlineStatus();
  return (
    <button type="button" disabled={!online} onClick={onSave}>
      {online ? "Save" : "Save (unavailable offline)"}
    </button>
  );
}

Accessibility essentials

  • Status changes go through role="status" / aria-live
  • Offline: assertive is reasonable; back-online can be polite
  • Don’t rely on color alone (icon + text)
  • Disabled controls explain why

Performance notes

  • Event listeners are cheap
  • Heartbeat interval should be long; use exponential backoff on failure
  • Avoid re-rendering whole app: put banner high in tree with narrow state subscription

Footguns

  1. Trusting navigator.onLine alone in production sync-critical apps
  2. Listener leaks
  3. SSR mismatch — default online, sync after mount
  4. Toast spam on flaky connections — debounce transitions
  5. Assuming fetch failures mean offline (could be 500)

Interview out-loud answer

I’d wrap navigator.onLine plus online/offline events in a hook, render a banner with a live region, and optionally confirm reachability with a cheap health check because lie-fi exists. MVP is indicator + disable destructive network actions. Full offline-first sync is a system-design follow-up with queues and conflict rules.

Further reading