navigator.onLine Lies - Build Offline Detection That Doesn't
The lie is in the name: navigator.onLine reports whether the OS has an active network interface. Connected to a coffee-shop portal that blocks everything? onLine: true. Router up, fiber cut? true. It answers "is there a cable" when you asked "does the internet work".
Trust, but verify
The events are still useful as hints - offline is reliably true when it fires (no interface at all), and online means "worth re-checking". Verification is a tiny authenticated-by-nothing ping:
async function isReallyOnline() {
try {
await fetch('/ping', { // a 204 endpoint on YOUR server
method: 'HEAD',
cache: 'no-store',
signal: AbortSignal.timeout(3000),
});
return true;
} catch {
return false;
}
}
addEventListener('online', () => updateStatus(await_check = true));
addEventListener('offline', () => setStatus(false)); // this one you can trust
Ping your own origin (third-party endpoints add privacy questions and CORS friction), keep it HEAD + no-store, and give it a tight timeout signal - a hanging connectivity check is its own kind of comedy.
The part users actually feel: don't lose their work
Detection is cosmetic; the substance is what happens to the form they submitted in a tunnel. Queue failed writes and replay them:
const QUEUE_KEY = 'retry-queue';
async function sendOrQueue(url, payload) {
try {
return await postJson(url, payload);
} catch {
const queue = JSON.parse(localStorage.getItem(QUEUE_KEY) ?? '[]');
queue.push({ url, payload, id: crypto.randomUUID(), at: Date.now() });
localStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
toast('Saved offline - will sync when you reconnect.');
}
}
async function flushQueue() {
const queue = JSON.parse(localStorage.getItem(QUEUE_KEY) ?? '[]');
const failed = [];
for (const item of queue) {
try { await postJson(item.url, { ...item.payload, idempotencyKey: item.id }); }
catch { failed.push(item); }
}
localStorage.setItem(QUEUE_KEY, JSON.stringify(failed));
}
addEventListener('online', flushQueue);
document.addEventListener('visibilitychange', () =>
document.visibilityState === 'visible' && flushQueue());
Details that matter: the randomUUID rides along as an idempotency key so the server can deduplicate replays (networks fail after delivery too - the request may have succeeded and the response died); flushing on visibilitychange catches the phone-unlocked-hours-later case that the online event misses; and the queue survives reloads because it lives in localStorage.
Full offline-first apps graduate to service workers and Background Sync (Chromium-only, mind). But this thirty-line version covers the actual common case - transient dead zones eating user input - and it's deployable this afternoon on any stack, including a plain PHP backend that just needs to respect the idempotency key.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.