network-connection
Real network state for the browser — verified by a healthcheck
request, never by navigator.onLine. No dependencies.
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. All of them need the answer to one question:
is the network actually there? The browser's
built-in answer, navigator.onLine, is not an answer —
refresh a PWA while offline and it cheerfully reports
true; sit on a Wi-Fi network with no internet behind it
and it never says anything else.
This library treats connectivity as a claim that needs
proof. The offline event is trusted
immediately — a definite negative. The online event is
only a rumor: it triggers a healthcheck request, and the state flips
to online when — and only when — that request comes back. On top of
that single source of truth sit a
change subscription for the UI and promise
helpers that let you write connectivity-aware flows as plain
await lines:
wait for the network,
react to a reconnect, or
restart interrupted work automatically.
Every member of NetworkConnection — including the
isOnline getter — throws until
init() has been called. That is
deliberate: a made-up default would be exactly the kind of
plausible-looking lie this library exists to kill. Call
await NetworkConnection.init("/api/health") once at
startup; when it resolves, the first healthcheck has settled and
isOnline is already telling the truth.
Getting started
Install#
npm i @dmytromykhailiuk/network-connection
None. No runtime dependencies, no framework — plain TypeScript
that works in any browser and in Node ≥ 18 (global
fetch). Ships ESM and CJS with type declarations for
both.
Quick start#
Initialize once at startup with the URL of a healthcheck endpoint — anything that responds when the network is up.
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
// pause a flow until the connection is back
await NetworkConnection.continueWhenOnline();
// run 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()),
);
One class, one process-wide state. The offline event
flips isOnline to false instantly; the
online event (and the optional
ping) flip it back only after a healthcheck
succeeds.
Why not navigator.onLine#
navigator.onLine answers a different question than the
one you are asking. It reports whether the OS has
some network interface up — not whether a request would
actually get anywhere. Two failure modes matter in practice:
Go offline, then refresh an installed PWA. The service worker
serves the app shell from cache, the page boots normally — and
navigator.onLine is true, because the
Wi-Fi interface is up. Your app happily fires requests into the
void. There was no offline event either — it fired in
the previous page, before the refresh threw that listener
away. Nothing in the browser's own signals tells the fresh page it
is offline.
The second mode is quieter: a network that is
connected but dead — airport Wi-Fi behind a captive
portal, a router whose uplink dropped, a hotspot out of data. The
interface stays up, navigator.onLine stays
true, and no offline event will ever fire.
Only an actual request can discover this — which is what the
optional pingInterval is for.
Hence the library's one rule:
a negative signal is trusted, a positive one is verified. The offline event says a definite "no" and is
applied immediately. Everything that claims "yes" — the
online event, page startup, a ping tick — must prove it
with a delivered healthcheck response first.
Reference
init#
static init(
healthcheckUrl: string,
options?: {
pingInterval?: number; // silent-loss detection, off by default
healthcheckTimeout?: number; // default 5000
method?: "HEAD" | "GET"; // default "GET"
},
): Promise<void>;
Configures the healthcheck, subscribes to the window's
online/offline events and runs the first
check. The returned promise resolves only after that check settles,
so the state is never a guess — including the
PWA refresh case, where the very first check is
what discovers the page is actually offline. Internally the state
starts as offline and the first successful check performs the
offline → online transition.
Calling init() twice throws — a second configuration
silently replacing the first hides bugs. Call
destroy() first when you
genuinely need to re-configure.
Options#
While online, re-run the healthcheck every N milliseconds to
catch a silent connection loss — the
connected-but-dead network that never fires an
offline event. Off when omitted; there is no
hidden background traffic unless you ask for it.
Abort the healthcheck request after N milliseconds and count
it as failed. Defaults to 5000. Without a
timeout, a hung TCP connection would keep
init() or a reconnect check waiting for the
platform default — minutes. A healthcheck that hangs
is a failed healthcheck.
HTTP method of the healthcheck request. Defaults to
GET, which every endpoint understands — including
a plain static file. Switch to
HEAD to skip the response body where the endpoint
supports it.
Healthcheck semantics#
The request is sent with cache: "no-store" and a
Cache-Control: no-cache header — without them, the
browser's HTTP cache, a PWA's service worker cache or an
intermediary proxy could answer on the network's behalf and fake a
success while offline, which is precisely the failure mode this
library exists to prevent.
Any delivered response counts as online — even a 404 or a
500.
A 500 still travelled through DNS, TCP and your reverse proxy; the
network demonstrably works. This library measures
reachability, not server health. Only a rejected request —
DNS failure, connection refused, timeout — means offline. Point
healthcheckUrl at something small and cheap; what it
answers matters less than that it answers.
A useful consequence:
the URL doesn't need a backend endpoint at all. A
tiny static file shipped with your build — /health.txt,
/favicon.ico, anything your hosting serves — is a
perfectly good healthcheck:
await NetworkConnection.init("/health.txt");
Concurrent triggers share one request: if the
online event, a ping tick and a
retry all demand a check at the same moment,
exactly one request goes out and all three await its result. A check
that was in flight when an offline event arrived is
discarded — the event is newer information than a response that left
before it.
isOnline#
static get isOnline(): boolean;
The current verified state. It changes in exactly three ways: the
offline event sets it to false
synchronously; a successful healthcheck (triggered by the
online event, a ping tick or a
retry) sets it to true; a failed
one sets it to false. Reading it before
init() throws — see the
lifecycle section for why.
Reading it is always synchronous and always current — there is no
"loading" state to handle. Transitions are consumed in one of two
ways: a callback per change through
subscribe(), or a await line through the
waiting helpers.
subscribe#
static subscribe(listener: (isOnline: boolean) => void): () => void;
Register a listener for changes of
isOnline
and get back the function that detaches it. Any number of listeners
can be subscribed at once — a single transition calls all of them,
in subscription order, with the new value.
const unsubscribe = NetworkConnection.subscribe((isOnline) => {
banner.hidden = isOnline;
banner.textContent = "You are offline — changes will sync when you reconnect";
});
unsubscribe(); // detach; calling it again is a no-op
Because the state behind it is healthcheck-verified, a listener
fires exactly when the truth changes — an offline
event, a check 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: you never get a reconnect callback for a
connection that isn't there.
The listener is not invoked when you subscribe, because the current value is already available synchronously and an immediate call would only duplicate it. Where a listener has to run once up front, hand it the value yourself — explicitly, in the order you want it:
const render = (isOnline: boolean) => { /* … */ };
render(NetworkConnection.isOnline);
const unsubscribe = NetworkConnection.subscribe(render);
The unsubscribe function is the whole teardown story, which makes it a drop-in for the cleanup contract of every framework:
const [isOnline, setOnline] = useState(NetworkConnection.isOnline);
// the effect returns the unsubscribe function directly
useEffect(() => NetworkConnection.subscribe(setOnline), []);
Guarantees#
The error is reported to the console and the remaining listeners still run. One broken subscriber must not take the network state machine down with it — nor stop the waiting helpers from resolving on the same transition.
A listener added from inside another listener first hears the
next change, not the one being delivered. One removed
mid-dispatch is not called in that round at all — an
unsubscribe() takes effect the moment it runs.
Inside a listener, isOnline already reads as the
value you were handed. If a listener flips the state itself,
the nested change is delivered to everyone and the interrupted
round is dropped — the last value every listener sees is the
current one.
Subscribing one function twice registers it twice and calls it
twice; each returned unsubscribe removes its own
registration and is a no-op after the first call.
destroy() detaches every
listener without a final call. The reset to
offline it performs is teardown, not an observation of the
network, and reporting it as a change would be exactly the kind of
plausible-looking lie this library exists to kill. After a
re-init(), subscribe again.
Waiting helpers#
static continueWhenOnline(): Promise<void>;
static continueWhenOffline(): Promise<void>;
Each resolves immediately when the state already matches, and otherwise on the next transition into it. They are the building block for "hold this until the network is back" written as a single line in the middle of ordinary async code:
async function drainQueue() {
for (const mutation of queue) {
// free when online; parks the loop when the connection drops
await NetworkConnection.continueWhenOnline();
await push(mutation);
}
}
Any number of callers can wait concurrently — a single transition
resolves them all. A waiter that immediately re-awaits the opposite
state lands in the next round, so
continueWhenOffline().then(() => continueWhenOnline())
always spans a real disconnect → reconnect cycle — which
is exactly what afterOnlineBack
is.
afterOnlineBack#
static afterOnlineBack(): Promise<void>;
Resolves after the connection has been lost and come back:
first continueWhenOffline(), then
continueWhenOnline(). When called while already
offline, the first half is instant and it simply waits for the
reconnect.
while (true) {
await NetworkConnection.afterOnlineBack();
// the connection just came back after an outage —
// local state may be stale, server state may have moved on
await resyncFromServer();
}
restartIfNotFinishedWhenOnline#
static restartIfNotFinishedWhenOnline<T>(fn: () => Promise<T>): Promise<T>;
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, so the
original error is thrown to you and nothing is retried. In full, the
three cases:
isOnline is already false — the
network took the blame. Wait for
continueWhenOnline(), run fn again.
A request can die from a connection drop before the
offline event fires — in that window
isOnline still says true. So the
catch runs a healthcheck. If it fails, the state flips to
offline and the retry path above takes over.
The healthcheck passed — the failure was genuine (a validation
error, a bug, a 500 your code turned into a throw). The
original error is rethrown untouched, and
fn is not retried. No retry loops on real bugs.
const receipt = await NetworkConnection.restartIfNotFinishedWhenOnline(
async () => {
const res = await fetch("/api/upload", { method: "POST", body });
if (!res.ok) throw new Error(`upload rejected: ${res.status}`);
return res.json();
},
);
A retry restarts fn from the beginning — the library
cannot know how far the previous attempt got before the connection
died. Make the work safe to repeat: idempotent endpoints, an
idempotency key, or a resumable protocol. Retries are deliberately
unbounded — the promise stays pending across any number of offline
periods until fn either finishes or fails for a
non-network reason.
One healthcheck request per call. Calls made at the same time share a single check, so a burst of them costs one request — a sequence of calls pays for each. What you get for it: work is never thrown at a connection that is already dead.
Call destroy() while a call is waiting for the
network and it rejects with the destroy error, just like a bare
continueWhenOnline() — that is not treated as
fn failing, so nothing is retried.
Silent losses & pingInterval#
The offline event covers the loud failures — Wi-Fi off,
airplane mode, cable out. It says nothing about the
connected-but-dead network: the interface is up,
packets go nowhere, no event ever fires. The only way to notice is
to ask, periodically:
await NetworkConnection.init("/api/health", { pingInterval: 30_000 });
While — and only while — the state is online, a healthcheck runs
every pingInterval milliseconds. A failed ping flips
the state to offline, wakes every
continueWhenOffline() waiter and stops the loop; the
next successful check (usually via the online event)
starts it again. Nothing pings while offline — reconnect detection
belongs to the online event, not to polling.
The loop is a chained setTimeout, not a
setInterval: the next ping is armed only after the
previous check settles, so a check slower than the interval can
never stack requests behind itself. And since concurrent triggers
share one in-flight request, a ping
colliding with an online event costs one request, not
two.
Lifecycle & destroy#
static destroy(): void;
Before init(), every member throws with the member's
name in the message. The tempting alternative — defaulting
isOnline to false — would make
continueWhenOnline() hang forever with no clue why; an
immediate, named error beats a silent deadlock.
destroy() undoes init(): it removes the
event listeners, stops the ping loop, discards any in-flight
healthcheck, detaches every
subscribe() listener (without
a final call) and resets the configuration. Pending waiters —
continueWhenOnline(),
continueWhenOffline(), and everything built on them —
are rejected rather than abandoned: a promise that
can no longer resolve must fail loudly, not leak. Calling
destroy() when not initialized is a no-op, which makes
it safe in test teardown and HMR dispose hooks:
if (import.meta.hot) {
import.meta.hot.dispose(() => NetworkConnection.destroy());
}
SSR & non-browser runtimes#
Importing the module is side-effect free, and
init() does not require a browser. Without a
window the online/offline
subscriptions are skipped — Node has no such events — while
everything else works: the healthcheck runs through the global
fetch (Node ≥ 18),
pingInterval ticks on plain timers, and the promise
helpers behave identically.
In an environment with no fetch at all, every
healthcheck simply reports offline — the library degrades to a
pessimist rather than crashing. For classic SSR the usual pattern
still applies: call init() from client bootstrap code,
not from module scope, so the server never pays for a healthcheck it
does not need.
TypeScript#
The entire surface is typed strictly — options are checked for
excess keys, restartIfNotFinishedWhenOnline carries its
function's result type through, and the class cannot be
instantiated:
const n = await NetworkConnection.restartIfNotFinishedWhenOnline(
async () => 42,
); // n: number — T flows through the retries
NetworkConnection.init("/health", { pingInterval: "30s" });
// ✗ Type 'string' is not assignable to type 'number'
NetworkConnection.init("/health", { interval: 30_000 });
// ✗ 'interval' does not exist in type 'NetworkConnectionOptions'
new NetworkConnection();
// ✗ Constructor of class 'NetworkConnection' is private
// (and throws at runtime for plain-JavaScript callers)
Exports#
Values
NetworkConnection
Types
NetworkConnectionOptions,
NetworkConnectionListener,
NetworkConnectionUnsubscribe