Skip to content

The online status is broken, let's fix it

Published: at 09:00 AM

⏰ 10 min read

Picture this. You are in a lift, or on a train in a tunnel. Your phone shows two bars. You open a web app, type a long message, and press send. The button spins. Nothing happens. The app does not say anything. It looks like it is working.

I have seen this problem in apps I worked on. The user thinks the app is fine. The app thinks the user is online. And between them, nothing moves. In this post, I explain why the “online” signal in the browser is not reliable, and I show you how to build a status that tells the truth.

A white and black modem router with four green lights

Photo by Misha Feshchak on Unsplash

Table of contents

Open Table of contents

What does “online” really mean?

When we say a user is online, we mean one thing: “My app can talk to my server right now.” But the word “online” can mean three different things, and a device can pass the first test while it fails the others.

Three questions: is the device on a network, is the internet reachable, is my server reachable. The browser only answers the first. 1. Is the device on a network? Wi-Fi or mobile data is connected navigator.onLine answers only this 2. Is the internet reachable? The router may be on, with no internet Captive portals fail here 3. Is my server reachable? The server may be down or blocked This is the one your app needs

Your app needs the answer to question three. The browser gives you the answer to question one. That gap is the whole problem.

Why the built-in indicator is not enough

The browser has a property called navigator.onLine, and two events, online and offline. They look like what you need. They are not.

What navigator.onLine really tells you

The browser cannot check more than that. It only looks at the state of the network connection on the device. It does not send a single request to anywhere.

Cases where it says “online” but you are not

Here are common situations where the value is true and your app still cannot work:

Squidward meme: Me, offline in a lift. The app: still online

In each case, the user sees an app that looks alive, and nothing they do is saved. That is the worst experience: no error, no hint, and no way to know why.

Other small problems

The online and offline events fire only when the network state changes on the device. If the connection is bad but never drops fully, no event fires. Also, the behavior can differ between browsers and between operating systems. A feature that depends on it can behave differently for each user.

This is fine meme: navigator.onLine equals true, no internet

The simple fix: let real requests tell you

The most basic way to know if a user is online is to look at what happens when your app talks to the server. If a request fails because of a network error, the user is probably offline.

In practice, this means:

  1. Wrap your calls in one place, so you see every network error.
  2. When a call fails with a network error (not a normal 4xx or 5xx response), show a clear message: “You seem to be offline. Your changes are not saved yet.”
  3. Keep the user’s work. Do not clear a form because the request failed.

This is a good start, and every app should do it. But it has one weak point: the user only learns about the problem after they press a button. For many apps, we can do better.

The better fix: check all the time

If your app needs live data, like a chat, a dashboard, or a booking screen, you want to know about a connection problem before the user does anything. You can do this in two ways, and which one to use depends on how your app talks to the server.

Option 1: long polling or a heartbeat

If your app fetches data from time to time, add a small, cheap request that runs on a timer. This is often called a heartbeat. If it succeeds, you are online. If it fails or takes too long, you are not.

Long polling is a related technique. The client asks the server for new data, and the server holds the request open until it has something to send or a time limit ends. Then the client asks again. Because there is always a request in flight, a failure shows up fast, and you get your status as a side effect of the data you already fetch.

Here is a simple heartbeat. It calls a tiny /health endpoint and treats a timeout like a failure:

async function checkConnection(timeoutMs = 4000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const response = await fetch("/health", {
      method: "HEAD",
      cache: "no-store",
      signal: controller.signal,
    });
    return response.ok;
  } catch {
    return false;
  } finally {
    clearTimeout(timer);
  }
}

A few details matter here:

Option 2: ping and pong on a WebSocket

If your app already uses a WebSocket, you have a better tool. The client sends a small “ping” message from time to time, and the server answers with a “pong”. If the pong does not come back in time, the connection is dead, even if the browser still says it is open.

This matters because a WebSocket can look open when the path under it is broken. The browser may take a long time to notice. A ping and pong check finds it in seconds.

function startPingPong(socket, { everyMs = 10000, waitMs = 4000, onChange }) {
  let waiting = null;

  const ping = setInterval(() => {
    socket.send(JSON.stringify({ type: "ping" }));
    waiting = setTimeout(() => onChange(false), waitMs);
  }, everyMs);

  socket.addEventListener("message", event => {
    if (JSON.parse(event.data).type === "pong") {
      clearTimeout(waiting);
      onChange(true);
    }
  });

  return () => {
    clearInterval(ping);
    clearTimeout(waiting);
  };
}

The server only needs one rule: when it receives a ping, send back a pong.

Which one should you use?

MethodGood forWeak point
navigator.onLineA quick hint that the device is fully offlineSays “online” when the internet is down
Check on failed requestsEvery app, as a baseThe user learns late
Heartbeat or long pollingApps that fetch data, with no socketExtra requests, a small delay
Ping and pong on WebSocketChat, live data, anything with a socketNeeds server support

My advice is to combine them. Use the real requests as the base, use a heartbeat or ping and pong for live apps, and use navigator.onLine only as a hint.

Waiting Skeleton meme: Waiting for the browser to tell me I am offline

Make the status feel calm

A correct status is not enough. A status that flickers between “online” and “offline” every few seconds is also bad. Users stop trusting it, and they get annoyed.

Wait for more than one failure

One failed request does not mean you are offline. A packet can get lost. Mark the user as offline only after two or three checks in a row fail. This is called debouncing, and it removes most of the flicker.

Come back faster than you leave

When the status is “offline”, check more often, for example every two or three seconds, so you can say “back online” quickly. When the status is “online”, check less often to save battery and data. Also use a growing delay between tries (a backoff), so a dead server does not get hit with thousands of requests.

Use the browser events as a trigger

The online event and the offline event are still useful. Do not use them as the answer. Use them as a signal to run your own check right now:

window.addEventListener("online", () => runCheck());
window.addEventListener("offline", () => setStatus(false));

For the offline event, you can trust it, because the device really has no network. For the online event, run your check first, because the device may be on a network that does not work.

Pause checks when nobody is looking

If the tab is hidden, there is no need to check every few seconds. Use the Page Visibility API (document.visibilityState) to slow down or stop the checks, and run one check when the tab becomes visible again.

What to show the user

A good status tells the user what happens next, not only what is wrong.

A short checklist

  1. Do not use navigator.onLine as the only source of truth.
  2. Handle network errors in one place and show a clear message.
  3. Add a heartbeat, or ping and pong if you use a WebSocket.
  4. Use a timeout and cache: "no-store" on your checks.
  5. Change the status only after two or three failed checks.
  6. Check more often while offline, and less often while online.
  7. Keep the user’s work, and send it later.
  8. Stop or slow the checks when the tab is hidden.

The takeaway

The browser can tell you that you are connected. Only your server can tell you that you are online.

The built-in indicator answers a smaller question than the one your app asks. When you build your own check, you answer the real question: “Can this user reach my server right now?” It takes a little code, and it saves your users from the silent failure that makes an app feel broken.

If you remember one thing: trust a request that you sent over a flag that the browser set.