@dmytromykhailiuk/network-connection
v1.1.0
Published
Real network state for the browser — healthcheck-verified isOnline, promise helpers for waiting on connectivity and auto-restarting interrupted work. navigator.onLine lies; this doesn't. Zero dependencies.
Downloads
388
Maintainers
Readme
@dmytromykhailiuk/network-connection
Real network state for the browser — healthcheck-verified isOnline, promise helpers for
waiting on connectivity and auto-restarting interrupted work. navigator.onLine lies; this
doesn't. Zero dependencies.
Full documentation: open Docs in a browser — every option, with examples, a table of contents and cross-links. This README is the short form.
⚠️ The rule that makes it work: a negative signal is trusted, a positive one is verified. The
offlineevent flipsisOnlinetofalseimmediately — a definite no. Everything that claims "yes" — theonlineevent, page startup, a ping tick — must prove it with a delivered healthcheck response first. And awaitinit()once at startup: every member throws until then, because a made-up default is exactly the kind of plausible-looking lie this library exists to kill.
Built for apps that keep working when the network doesn't — PWAs, offline-first tools, anything
with a sync queue or a long-running upload. They all need the answer to one question: is the
network actually there? navigator.onLine is not that answer. Refresh an installed PWA while
offline: the service worker serves the shell from cache, the page boots, and navigator.onLine
reports true — the Wi-Fi interface is up, so the browser is technically not wrong, just
useless. The offline event fired in the previous page, before the refresh threw that
listener away. And a connected-but-dead network (captive portal, dead uplink) keeps onLine
at true forever without a single event.
So this library never reads navigator.onLine. The state changes in exactly three ways: the
offline event sets it to false synchronously; a successful healthcheck request sets it to
true; a failed one sets it to false. On top of that sit promise helpers that turn
connectivity-aware flows into plain await lines.
Install
npm i @dmytromykhailiuk/network-connectionQuick start
import { NetworkConnection } from "@dmytromykhailiuk/network-connection";
// resolves after the first healthcheck — isOnline is truthful from here on
await NetworkConnection.init("/api/health", {
pingInterval: 30_000, // optional: re-verify every 30 s while online
});
NetworkConnection.isOnline; // boolean — the real state
// react to every change; returns the unsubscribe function
const unsubscribe = NetworkConnection.subscribe((isOnline) => {
offlineBanner.hidden = isOnline;
});
await NetworkConnection.continueWhenOnline(); // park a flow until the network is back
// work that must survive connection drops: on a network failure it
// waits for the reconnect and starts over
const orders = await NetworkConnection.restartIfNotFinishedWhenOnline(
() => fetch("/api/orders").then((res) => res.json()),
);API
NetworkConnection.init(healthcheckUrl, {
pingInterval?: number; // re-check every N ms while online — detects silent losses
healthcheckTimeout?: number; // abort the check after N ms and count it as failed (5000)
method?: "HEAD" | "GET"; // healthcheck request method ("GET")
}); // Promise<void> — resolves after the first check
NetworkConnection.isOnline; // boolean; throws before init()
NetworkConnection.subscribe(listener); // () => void — call it to unsubscribe
NetworkConnection.continueWhenOnline(); // resolves when online (now or later)
NetworkConnection.continueWhenOffline(); // mirror
NetworkConnection.afterOnlineBack(); // resolves after a disconnect → reconnect cycle
NetworkConnection.restartIfNotFinishedWhenOnline(fn); // verify, run fn, retry across offline periods
NetworkConnection.destroy(); // undo init(); rejects pending waitersCalling init() twice throws — call destroy() first to re-configure.
subscribe
const unsubscribe = NetworkConnection.subscribe((isOnline) => {
offlineBanner.hidden = isOnline;
});
unsubscribe(); // detach — calling it again is a no-opAny number of listeners can be subscribed at once; one transition calls them all, in
subscription order, with the new value. Only changes are delivered — the listener is not
called on subscription, because the current value is already there synchronously. When a
listener needs an initial run, pass it yourself: listener(NetworkConnection.isOnline).
Because the state is healthcheck-verified, a listener fires exactly when the truth changes: an
offline event, a healthcheck that came back after one didn't, or a ping tick that caught a
silent loss. An online event whose healthcheck fails changes nothing, so nothing is delivered.
Details worth knowing:
- A listener that throws is contained — the error is logged and the remaining listeners still run. One broken subscriber must not take the network state machine down with it.
- Subscribing and unsubscribing from inside a listener is safe. A listener added during a dispatch first hears the next change; one removed during a dispatch is not called in that round.
destroy()drops every subscription without a final call: the state it resets to is a teardown, not an observation of the network. Re-subscribe after re-init().
// React
useEffect(() => NetworkConnection.subscribe(setOnline), []);Healthcheck semantics
- Any delivered response counts as online — even a 404 or 500. A 500 still travelled through DNS and TCP; the network demonstrably works. This measures reachability, not server health. Only a rejected request (DNS failure, connection refused, timeout) means offline.
- The URL doesn't need a backend endpoint. Because any delivered response counts, a tiny
static file shipped with your build works just as well —
/health.txt,/favicon.ico, anything your hosting serves:NetworkConnection.init("/health.txt"). No server code, no extra route. - The request is sent with
cache: "no-store"and aCache-Control: no-cacheheader, so neither the browser's HTTP cache, a PWA's service worker cache, nor an intermediary proxy can answer on the network's behalf and fake a success while offline. - A check that hangs is aborted after
healthcheckTimeoutand counts as failed. - Concurrent triggers (the
onlineevent, a ping tick, a retry) share one in-flight request. A check that was in flight when anofflineevent arrived is discarded — the event is newer information.
restartIfNotFinishedWhenOnline
A wrapper for work that has to survive a connection drop. It checks that the network is really
there before starting fn — and if it isn't, it just waits for the connection to come back and
starts then.
If fn fails, it checks the network again. Gone → wait for the reconnect and run fn from
scratch, as many times as it takes. Network fine → the failure was real (a validation error, a
bug, a 500 your code threw on), so the original error is thrown to you and nothing is
retried. Retries are unbounded by design — the promise stays pending across any number of
offline periods.
Two details worth knowing: the check before fn is one healthcheck request per call (calls made
at the same time share a single one), and destroy() while a call is waiting for the network
rejects it with the destroy error instead of retrying.
A retry restarts fn from the beginning, so make the work safe to repeat: idempotent
endpoints, an idempotency key, or a resumable protocol.
Silent losses & pingInterval
The offline event covers the loud failures. It says nothing about a network that is connected
but dead — airport Wi-Fi behind a captive portal, a router whose uplink dropped. Pass
pingInterval and, while online, a healthcheck runs every N ms; a failed ping flips the state
to offline and stops the loop until the connection verifiably returns. The loop is a chained
setTimeout — a check slower than the interval never stacks requests behind itself. No
pingInterval, no background traffic.
SSR
init() works without a window — the event subscriptions are skipped, the healthcheck runs
through the global fetch (Node ≥ 18), timers and promise helpers behave identically. With no
fetch at all every check reports offline — the library degrades to a pessimist rather than
crashing.
TypeScript
const n = await NetworkConnection.restartIfNotFinishedWhenOnline(async () => 42);
// n: number — T flows through the retries
NetworkConnection.init("/health", { pingInterval: "30s" }); // ✗ string is not a number
new NetworkConnection(); // ✗ constructor is private (and throws at runtime)License
MIT
