How to detect when the user goes offline — and why navigator.onLine lies in a café

You're in a café. Your phone joined the Wi-Fi, full bars. You open your app, tap "Send" — and the spinner spins. Forever.
Even though you have a check:
if (!navigator.onLine) showOfflineBanner();
It stays silent. Here's the catch: navigator.onLine doesn't ask "is there internet?", it asks "is the device connected to any network at all?" A café Wi-Fi that only lets you online after a login page is still a network. So: true.
Let's build a check you can actually trust.
What navigator.onLine really means
The rule is simple and lopsided:
false— offline, for sure. No Wi-Fi, no mobile data, airplane mode.true— the device is connected to a network. Whether there's internet behind it — unknown.
MDN says it plainly: true doesn't mean the browser can reach the internet. False "online" comes from Wi-Fi without access, a dead ISP, virtual network adapters on a computer.
So navigator.onLine is a good quick first filter. Only a real request tells the truth.
Step by step: a check you can trust
-
Set up your own "ping". The lightest endpoint on your own server, answering
204with no body. Say,/api/ping. Why yours and not google.com: what matters is whether your server is reachable. Someone else's site may be blocked or answer without CORS. -
Write a check with a timeout. Without one, a request on a "dead" network can hang for a minute.
async function isReallyOnline() { if (!navigator.onLine) return false; // definitely offline — skip the request try { const res = await fetch("/api/ping", { cache: "no-store", // don't let the cache answer for the network signal: AbortSignal.timeout(5000), // 5 seconds is enough }); return res.ok; } catch { return false; // timeout or network unreachable } }cache: "no-store"matters here. Otherwise the browser may pull the response from cache and say "all good". More on surprises like that in the breakdown of why fetch returns cached data. -
Listen to the
onlineandofflineevents. They fire onwindowwhennavigator.onLinechanges. Onoffline— show the banner right away. Ononline— don't take its word for it, re-check with the ping.window.addEventListener("offline", () => setOnline(false)); window.addEventListener("online", async () => setOnline(await isReallyOnline())); -
Re-check when requests fail. Any of your
fetchcalls fails with a network error — runisReallyOnline(). While offline, check every 15–30 seconds. Not more often: that's data and battery. -
Wrap it in one hook. In React it's a piece of state every screen can read.
function useOnline() { const [online, setOnline] = useState(navigator.onLine); useEffect(() => { const check = async () => setOnline(await isReallyOnline()); const goOff = () => setOnline(false); window.addEventListener("online", check); window.addEventListener("offline", goOff); check(); const id = setInterval(() => { if (!online) check(); }, 20000); return () => { window.removeEventListener("online", check); window.removeEventListener("offline", goOff); clearInterval(id); }; }, [online]); return online; } -
Show it like a human. Not a full-screen modal, but a thin bar at the top: "No connection — we'll save this and send it when you're back online." Send buttons are disabled or queue the action. When the connection returns — a short toast "Back online", and the bar goes away.
What you get
- In airplane mode the bar appears instantly — via the
offlineevent. - On café Wi-Fi with no internet — within a few seconds, once the ping times out. No endless spinner.
- When the connection comes back, the app notices on its own, no reload needed.
Common mistakes
- Pinging someone else's site. A browser request to google.com runs into CORS or a block — and you decide there's no internet while your server is fine.
- A ping with no timeout. On a "dead" network
fetchdoesn't fail right away, it waits. The banner shows up a minute later, after the user has left. - Pinging every second. A hundred users online means a hundred extra requests per second to your server. Check on events and rarely, only while offline.
- Trusting the
onlineevent alone. It fires when the Wi-Fi comes back, not the internet. Without a re-check, the app declares "all good" too early.
How do I test this?
In DevTools, open the Network tab and choose "Offline" — navigator.onLine becomes false and the event fires. For "a network with no internet", block just /api/ping with Request blocking: onLine stays true, but the ping fails.
Can I tell connection quality, not just whether it exists?
There's navigator.connection with an effectiveType field ("4g", "3g"), but it isn't everywhere: Safari and Firefox don't have it. Measuring your own ping's response time is more reliable.
Do I need a ping if the app already works offline?
Yes. Offline mode decides what to show without a network; the ping decides when it's time to sync. Without it, the app won't know it's time to send what it saved up.
Short story-lessons, an agent simulator and daily practice — in our mobile app. Free.




