network-connection
network-connection 1.1.0

Real network state for the browser — healthcheck-verified, with promise helpers for waiting and retrying. Zero dependencies.

Contents

Contents

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.

healthcheck-verified promise-based waiting auto-restart on reconnect detects silent losses SSR-safe zero dependencies
Await init() once, before anything else

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#

sh
npm i @dmytromykhailiuk/network-connection
Requirements

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.

ts
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:

The PWA refresh lie

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#

ts
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#

pingIntervalnumber

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.

healthcheckTimeoutnumber

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.

method"HEAD" | "GET"

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:

ts no server code, no extra route
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#

ts
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#

ts
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.

ts an offline banner
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.

Changes only — not called on subscription

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:

ts initial run + updates
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:

ts React
const [isOnline, setOnline] = useState(NetworkConnection.isOnline);

// the effect returns the unsubscribe function directly
useEffect(() => NetworkConnection.subscribe(setOnline), []);

Guarantees#

a listener that throwscontained

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.

subscribing mid-dispatchnext change

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.

the value you receivenever stale

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.

the same listener twicetwo subscriptions

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() drops subscriptions silently

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#

ts
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:

ts draining a sync queue
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#

ts
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.

ts re-sync after every outage
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#

ts
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:

offline at the time→ retry

isOnline is already false — the network took the blame. Wait for continueWhenOnline(), run fn again.

nominally online→ verify

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.

network is fine→ rethrow

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.

ts an upload that survives the subway
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();
  },
);
fn may run more than once

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.

What the check before fn costs

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:

ts
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#

ts
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:

ts Vite HMR
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:

ts
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