Skip to content
Carl Victor Fontanos
Carl Victor Fontanos

Carl Victor Fontanos

Software Engineer

I build web applications and share what I learn along the way.

© 2026

navigator.onLine Lies - Build Offline Detection That Doesn't

C
Carlo Fontanos
· 2 min read

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.

C
Written by Carlo Fontanos

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.

Message sent!

Your details are only used to reply to you.

Keep reading