# Use net-watch with AI (/docs/ai/use-with-ai) AI coding assistants write better net-watch code when they read the real docs instead of guessing from memory. This page shows how to hand them the docs, a rules snippet to keep in your repo, and prompts for common jobs. ## Give your assistant the docs [#give-your-assistant-the-docs] This site publishes its content in formats that are easy for a model to read. | What | Where | Give it to an assistant when | |---|---|---| | Index of pages | `/llms.txt` | The assistant can browse. It reads the index and opens the pages it needs. | | All docs in one file | `/llms-full.txt` | You want a whole-project task done, like adding net-watch to an app. It costs the most context, and the assistant needs nothing else. | | One page as Markdown | Add `.md` to the page URL, for example `/docs/quick-start.md` | The task is small, like a ping endpoint or a banner. Paste or link only that page. | Every docs page also has two buttons at the top. **Copy Markdown** puts the page's Markdown on your clipboard, ready to paste into a chat. **Open** lets you send the page to an AI chat service. A good rule of thumb: for a chat tool, paste one or two pages. For an agent that works inside your repo, point it at `/llms.txt` or `/llms-full.txt` and let it read. ## Rules for AGENTS.md, CLAUDE.md or Cursor rules [#rules-for-agentsmd-claudemd-or-cursor-rules] Assistants that work in your repo read a rules file. Add this to `AGENTS.md`, `CLAUDE.md` or your Cursor rules, and replace `YOUR-SITE` with the address of this docs site. ```md title="AGENTS.md" ## net-watch This app uses net-watch (`@net-watch/core` and a framework adapter) to know whether the user is online and how good the connection is. - Before writing net-watch code, read https://YOUR-SITE/llms.txt and open the pages you need. Do not rely on memory. - Use one shared watcher for the whole app. Create it once in a module with `createNetworkWatcher({ pingUrl })` and import it everywhere. Never create a watcher inside a component or a function that runs many times. - Make HTTP requests through `watchFetch(watcher)` from `@net-watch/core/fetch`. For other HTTP clients, call `watcher.report(true, ms)` or `watcher.report(false)` from an interceptor. - Never poll `navigator.onLine`, never listen to the `online` or `offline` events, and never write a ping timer yourself. The watcher already does this. - Read the state with the framework adapter (`useNetworkStatus`, or `injectNetworkStatus` in Angular), or with `watcher.getState()` and `watcher.subscribe()`. - Create the banner with `createBanner` from `@net-watch/core/banner` inside an effect or mount hook, never during render or on the server. - Treat undefined metrics as unknown. Do not replace `rtt`, `downlink` or `effectiveType` with a guess. - Only use options and fields that appear in the docs. Never invent an option. - The ping endpoint must be same origin, HTTPS, answer HEAD with 204 and Cache-Control: no-store, and be excluded from service worker caches. ``` ## Ready-made prompts [#ready-made-prompts] Copy a prompt and paste it into your assistant. Each prompt is self-contained, and the address of this docs site is already filled in (on a local copy it shows `YOUR-SITE`: replace it by hand). {`Add net-watch to this project. Read /llms-full.txt from the net-watch docs site (https://YOUR-SITE/llms-full.txt) first, and follow the docs, not your memory. Do not invent options or fields. 1. Detect the framework from package.json (React, Vue, Angular, Next.js, Nuxt, or plain TypeScript). 2. Install the right packages: @net-watch/core, plus @net-watch/react, @net-watch/vue or @net-watch/angular for that framework. Use the package manager the project already uses. 3. Create ONE watcher in its own module with createNetworkWatcher. Use pingUrl: '/api/ping' (or the project's existing health route) and import this watcher everywhere. 4. Wrap the app's fetch calls with watchFetch(watcher) from @net-watch/core/fetch, and use the wrapped function in the API layer. Do not patch window.fetch. 5. Add an offline banner with createBanner from @net-watch/core/banner, created in an effect or mount hook so it never runs on the server. 6. Add a small status component that shows online or offline and the quality with the rtt when it is known. 7. If there is no ping route, add one that answers HEAD and GET with 204 and Cache-Control: no-store. Make the smallest change that works, run the type checker and the tests, and tell me which files you changed.`} {`Add a ping endpoint for net-watch to this backend. Read https://YOUR-SITE/docs/guides/ping-endpoint.md first. Requirements: - Same origin as the web app, served over HTTPS. - Path: /ping (or /api/ping if the app puts every route under /api). - Answers HEAD and GET with status 204, an empty body and the header Cache-Control: no-store. - No authentication, no session lookup, no database access, no logging of each request. - Registered before any auth middleware, and not rate limited like normal API routes. - Not cached by a CDN, a reverse proxy or the app's service worker. If there is a service worker, add a rule so it never handles this path. Detect the backend framework and write the idiomatic version. Then show me a curl -I command to verify it, and point createNetworkWatcher at it with the pingUrl option.`} {`Show the connection quality in this app's UI with net-watch. Read https://YOUR-SITE/docs/concepts/network-quality.md and https://YOUR-SITE/docs/reference/state.md first. - Reuse the app's existing watcher. If there is none, stop and tell me, instead of creating a second one. - Read the state with the framework adapter (useNetworkStatus, or injectNetworkStatus in Angular). - Show a small indicator with the quality (excellent, good, fair, poor, unknown) and the rtt in ms. - rtt, downlink and effectiveType can be undefined. Show a placeholder when they are. Never estimate them from other values. - When offline, show Offline and hide the quality. - Server rendering: on the server the quality is always unknown. Avoid a hydration mismatch by rendering quality-dependent UI after the component mounts. - Do not add polling or timers. The watcher already updates the state. Run the type checker when you are done.`} {`Review how this project uses net-watch. Read https://YOUR-SITE/llms-full.txt first and compare the code with the docs. Report problems, do not change code yet. Check each item and answer pass or fail with the file and line: 1. One watcher: createNetworkWatcher is called once, in one module. No watcher is created inside a component, a hook or a function that runs repeatedly. 2. pingUrl is set, points to a same-origin HTTPS endpoint, and the endpoint answers HEAD with 204 and Cache-Control: no-store. 3. The ping URL is not cached: not by a service worker, a CDN or a proxy. 4. No manual polling: no setInterval or setTimeout that checks navigator.onLine or calls the ping URL, and no direct online or offline event listeners. 5. Requests go through watchFetch(watcher) or call watcher.report from an interceptor. window.fetch is not patched. 6. The banner is created inside an effect or a mount hook, and destroyed on cleanup. 7. SSR safe: nothing reads window or document at import time, and quality-dependent UI renders after mount. 8. No invented options or fields. Every option and state field exists in the docs. End with a list of fixes ordered by impact.`} ## Tips [#tips] - **Say which version you use.** Run `npm ls @net-watch/core` and include the version in your prompt. All four packages share one version number. - **Paste the state shape.** Give the assistant the `NetworkState` fields from [the state reference](/docs/reference/state). It stops it from making up fields like `speed` or `isOnline`. - **Name your framework and router.** "Next.js App Router" and "Vue 3 with Nuxt" lead to different SSR choices. - **Ask it to run the type checker.** A wrong option name fails `tsc` right away, which catches the most common mistake. - **Keep the rules file in the repo.** The rules snippet works for every later session, so you do not repeat yourself in each prompt. - **Review the ping endpoint yourself.** Check that it is not cached and runs before auth. An assistant can get these details wrong, and the app would look online forever. # Browser support (/docs/concepts/browser-support) net-watch runs in **Chrome and Edge 90, Firefox 90, and Safari and iOS 15**, and anything newer. Every platform API in core is checked against that floor. An API that a floor browser lacks is either not used, or used only as a hint with a fallback. The tests run in real browsers, not a simulated DOM: the suite runs in Vitest browser mode on Playwright's Chromium, Firefox and WebKit. ## What works where [#what-works-where] | API | Chrome / Edge | Firefox | Safari / iOS | Role in net-watch | |---|---|---|---|---| | `navigator.onLine`, `online` / `offline` events | Yes | Yes | Yes | Required. Layer 1 | | `visibilitychange`, `pageshow` | Yes | Yes | Yes | Required. Suspects that trigger a check. `pageshow` covers cache restores on iOS | | `fetch` with HEAD, `no-cors`, `no-store` | Yes | Yes | Yes | Required. The probe | | `AbortController`, `performance.now()` | Yes | Yes | Yes | Required. Probe timeout and round-trip time | | `PerformanceObserver` for `resource` entries | Yes | Yes | Yes | Hint. Source of `downlink` | | `PerformanceResourceTiming.transferSize` | Yes | Yes | 16.4 and later | Hint. Below 16.4 it reads 0, so no samples and `downlink` stays `undefined` | | `navigator.connection` | Yes | No | No | Hint. Every field falls back to `undefined` (`saveData` to `false`) | | CSS `:where()` | 88 and later | 78 and later | 14 and later | Banner defaults with zero specificity, so your CSS wins | | `AbortSignal.timeout` | 103 and later | 100 and later | 16 and later | Not used | "Required" APIs exist in every floor browser, and the core depends on them. "Hint" APIs enrich the state when present. Their absence never breaks detection, it only leaves a field `undefined`. ### What each field needs [#what-each-field-needs] | Field | Chrome / Edge | Firefox | Safari / iOS | |---|---|---|---| | `online`, `since` | Yes | Yes | Yes | | `quality`, `rtt` | Yes | Yes | Yes, once a sample exists | | `downlink` | Yes | Yes | 16.4 and later | | `effectiveType`, `saveData` | Yes | `undefined`, `false` | `undefined`, `false` | | `type` | Chrome on Android and ChromeOS only | `undefined` | `undefined` | The [state reference](/docs/reference/state) has the full table. ## Quirks the library handles [#quirks-the-library-handles] You do not need to work around these, but knowing them explains some of the design. ### Network errors have different messages [#network-errors-have-different-messages] A failed `fetch` throws a `TypeError` in every engine, with a different message: `Failed to fetch` in Chromium, `NetworkError when attempting to fetch resource.` in Firefox, `Load failed` in Safari. Matching on the message would break in two of three browsers, so `watchFetch` checks `err instanceof TypeError` and never reads the text. ### iOS freezes timers in background tabs [#ios-freezes-timers-in-background-tabs] Safari on iOS freezes JavaScript timers in background tabs. A retry timer set while offline may not fire on time, or until the tab is visible again. net-watch does not rely on timers alone. It re-checks on `visibilitychange` when the tab becomes visible and on `pageshow` when a page is restored from the back/forward cache. ### Virtual adapters keep `navigator.onLine` true [#virtual-adapters-keep-navigatoronline-true] Docker, VPN clients and Hyper-V install virtual network adapters. With them present, `navigator.onLine` can stay `true` after you switch Wi-Fi off. That is the main reason a `pingUrl` exists, and the reason net-watch only trusts `false`. To test your offline UI, use DevTools, Network, Offline. Switching Wi-Fi off on a development machine can mislead you. ### No `AbortSignal.timeout` [#no-abortsignaltimeout] `AbortSignal.timeout()` arrived in Safari 16, and the floor is Safari 15. The probe uses an `AbortController` and a `setTimeout` instead, which works everywhere. ### Syntax target [#syntax-target] The package builds to `es2020`, so newer syntax such as `??=` is lowered for the floor browsers. ## Not supported [#not-supported] - Browsers below the floor. They may work, but they are not tested. - Non-browser runtimes. Importing the package works anywhere, because it is [SSR-safe](/docs/guides/ssr), but detection only runs where `window` exists. For React Native, use `@react-native-community/netinfo`. ## Module formats [#module-formats] `@net-watch/core`, `@net-watch/react` and `@net-watch/vue` ship both ES modules and CommonJS, so `import` and `require()` both work, including in Jest's CommonJS mode and on Node versions that cannot `require()` ES modules. `@net-watch/angular` is ES modules only, like every Angular library. Types are included for both. Use one format per app. If a bundle loads both the ESM and the CommonJS copy of `@net-watch/core`, `sharedWatcher()` creates one watcher per copy. ## See also [#see-also] - [Detecting offline](/docs/concepts/detecting-offline): how the layers combine. - [Network quality](/docs/concepts/network-quality): where `downlink` comes from. # Detecting offline (/docs/concepts/detecting-offline) net-watch decides `online` from three layers of evidence, cheapest first. This page lists them, the rules that combine them, and the limits. For the bigger picture, start with [How it works](/docs/concepts/how-it-works). ## The three layers [#the-three-layers] | Layer | Signal | Cost | Trust | |---|---|---|---| | 1. Browser signals | `navigator.onLine`, the `online` and `offline` events | 0 requests | `false` is reliable. `true` only means a network interface is up | | 2. Real requests | `watchFetch`, or `report()` called by you | 0 extra requests | A success proves you are online unless layer 1 says `false`. A network error is only a suspicion | | 3. The probe | One `HEAD` request to `pingUrl` | 1 request, only when needed | Confirms or refutes a suspicion | The probe uses `fetch` with `method: 'HEAD'`, `mode: 'no-cors'` and `cache: 'no-store'`. It counts any response as success, because with `no-cors` the status is not readable. A network error, or no answer within `timeout` (5 seconds by default), counts as failure. ## The rules, in order [#the-rules-in-order] 1. **The `offline` event or `navigator.onLine === false` means offline, immediately.** No probe is sent. Layer 1 is reliable when it says `false`. 2. **`report(true)` or a successful probe means online, unless `navigator.onLine` is `false`.** `navigator.onLine === false` always wins. A cache-backed response, from a service worker or the HTTP cache, can succeed while the browser is offline. That is not proof of connectivity. 3. **A suspect starts a check, which sends one probe.** The suspects are listed below. 4. **Without `pingUrl`, a check resolves at once to `navigator.onLine`,** and `report(false)` is ignored, because nothing can confirm it. 5. **At most one probe is in flight.** `check()` during a probe returns the pending promise. 6. **While offline with a `pingUrl`, the watcher retries with backoff:** 2 seconds, doubling, capped at `maxRetryDelay`. The delay resets when the watcher goes online. It also re-checks when the tab becomes visible or a page is restored from the cache, because iOS freezes timers in background tabs. 7. **Online and idle sends zero requests** unless you opt in to `heartbeat`. There is no fixed-interval polling by default. 8. **`heartbeat` (opt-in, needs `pingUrl`)** probes every `heartbeat` milliseconds when the watcher is online and the tab is visible. 9. **`watchFetch` never reports an aborted request** (`AbortError`). ## Suspects [#suspects] A suspect is a reason to doubt the current state. Each one triggers a check: | Suspect | When it happens | |---|---| | Startup | The watcher is created in a browser | | `report(false)` | A real request failed with a network error | | The `online` event | The browser says a connection came up | | `visibilitychange` to visible | The user returned to the tab | | `pageshow` from the back/forward cache | The page was restored from the cache | The `offline` event is not a suspect. It is a verdict, handled by rule 1. ## What is detected, with and without `pingUrl` [#what-is-detected-with-and-without-pingurl] | Situation | Without `pingUrl` | With `pingUrl` | |---|---|---| | Wi-Fi switched off, cable unplugged, airplane mode | Detected (the browser reports it) | Detected, no probe needed | | Back online after the above | Detected through the `online` event | Detected, and a probe confirms it | | Wi-Fi connected, router has no internet | Not detected | Detected when the next check fails | | Captive portal | Not detected | Detected when `pingUrl` is HTTPS, because the portal breaks TLS and the probe fails | | Virtual adapter (Docker, VPN, Hyper-V) keeps `navigator.onLine` true | Not detected | Detected when the next check fails | | A network error from `watchFetch` | Ignored, nothing can confirm it | Triggers a probe | | Connection dies while the app is idle | Detected only if the browser notices | Detected on the next suspect, or within `heartbeat` ms if set | The short version: without `pingUrl`, net-watch trusts the browser's `false` and nothing else. The [ping endpoint guide](/docs/guides/ping-endpoint) shows how to add one. "The next check" means the next suspect or heartbeat. Detection is not instant, because net-watch does not poll. If you need a faster answer for one action, call `await watcher.check()` before it. ## Reporting your own evidence [#reporting-your-own-evidence] `watchFetch` calls `report()` for you. Call it yourself for traffic that does not go through `fetch`: a WebSocket, a service worker message, a native bridge such as Capacitor. ```ts import { createNetworkWatcher } from '@net-watch/core' const watcher = createNetworkWatcher({ pingUrl: '/api/ping' }) const socket = new WebSocket('wss://example.com/live') socket.addEventListener('open', () => watcher.report(true)) socket.addEventListener('error', () => watcher.report(false)) ``` - **`report(true)`** says a real request succeeded. The watcher goes online, unless `navigator.onLine` is `false`. Pass a second argument, the round-trip time in milliseconds, and it also becomes a latency sample: `watcher.report(true, 84)`. - **`report(false)`** says a request failed. It is a suspicion, not a verdict. With a `pingUrl` it triggers a check. Without one it does nothing. Do not call `report(false)` for HTTP errors such as 404 or 500. The server answered, so the user is online. `watchFetch` follows the same rule: any response counts as success, and only a network failure raises suspicion. ## Why `navigator.onLine === false` always wins [#why-navigatoronline--false-always-wins] Success evidence can be stale or fake while the browser is offline. A service worker can answer from its cache. The HTTP cache can serve a stored response. A probe that was already in flight can resolve after the connection dropped. None of these prove the user can reach your server. So when the browser says `false`, net-watch stays offline and ignores `report(true)` and probe results until the browser says otherwise. The asymmetry is deliberate: `false` is reliable, `true` is not. ## Backoff and heartbeat [#backoff-and-heartbeat] While offline, probes are spaced 2, 4, 8, 16, then 30 seconds apart (or `maxRetryDelay`, if you set it). This keeps a long outage cheap. Each retry sends a probe only if `navigator.onLine` is `true`. If it is still `false`, the retry confirms offline without sending a request. The heartbeat is for apps that need to notice a dead connection while the user is idle, for example a live dashboard. ```ts const watcher = createNetworkWatcher({ pingUrl: '/api/ping', heartbeat: 30_000, }) ``` It skips hidden tabs and the offline state. It also refreshes `rtt` and `quality`. Most apps do not need it, because their real requests already feed the watcher. ## What it cannot detect [#what-it-cannot-detect] - **"Your server is down" versus "the user is offline".** The probe goes to `pingUrl`. If that endpoint is unreachable, the user looks offline, whatever the cause. Serve the ping route from infrastructure that stays up with your app, such as your edge or CDN. - **Other hosts.** A same-origin `pingUrl` proves the user can reach that origin. It says nothing about an API on another domain that a firewall blocks. - **HTTP errors.** The probe cannot read status codes, and any response counts as reachable. A server answering 500 still reads as online. - **Slow is not offline.** A connection that works but is painfully slow stays `online: true`. Use [`quality`](/docs/concepts/network-quality) for that. - **A dying connection between suspects.** Without a `heartbeat` or traffic, nothing looks at the connection until the next suspect. - **Without `pingUrl`, anything the browser does not report.** Router with no uplink, captive portals and virtual adapters all look online. - **Signal strength.** Web pages cannot read it in any browser. ## Related [#related] - [Ping endpoint guide](/docs/guides/ping-endpoint): what to serve at `pingUrl`. - [watchFetch guide](/docs/guides/watch-fetch): turn every request into evidence. - [API reference](/docs/reference/api): `check`, `report` and `waitForOnline`. # How it works (/docs/concepts/how-it-works) net-watch has one idea: **many cheap signals in, one state out**. The watcher collects evidence from the browser and from your own requests, decides whether the user is online, measures how good the link is, and publishes the result as a single immutable object. Everything else, the banner, the React hook, the Vue composable, only reads that object. ## The three signal layers [#the-three-signal-layers] The watcher asks the cheapest source first and only escalates when it has to. | Layer | Signal | Cost | How far to trust it | |---|---|---|---| | 1 | `navigator.onLine` and the `online` / `offline` events | No requests | `false` is reliable. `true` only means a network interface is up | | 2 | Your own requests, through `watchFetch` or `report()` | No extra requests | A success proves you are online, unless layer 1 says `false`. A network error is only a suspicion | | 3 | A `HEAD` request to your `pingUrl` | One request, only when needed | Confirms or refutes the suspicion | The [Detecting offline](/docs/concepts/detecting-offline) page has the exact rules. This page shows how they fit together. ## Suspects trigger a probe [#suspects-trigger-a-probe] Most of the time the watcher does nothing. It sends a probe only when it has a reason to doubt the current answer. These events are **suspects**: - startup, when the watcher is created - the browser's `online` event - the tab becoming visible again - a page restored from the back/forward cache (`pageshow`) - `report(false)`, which is what `watchFetch` sends after a network error A suspect moves the watcher to a checking step. With a `pingUrl` it sends one probe. Without one it answers from `navigator.onLine` at once, and `report(false)` is ignored because nothing could confirm it. **Single flight.** Only one probe is ever in the air. If a second suspect arrives while a probe is running, `check()` returns the pending promise instead of sending another request. ## A story: the train [#a-story-the-train] Say a user opens your app on a train. You created the watcher with a `pingUrl`, wrapped `fetch` with `watchFetch`, and mounted the banner. 1. **The app loads.** Startup counts as a suspect, so the watcher sends one probe. It succeeds in 120 ms, so the state is `online: true`, and the first RTT sample seeds the smoothed value. 2. **The user browses.** Each API call goes through `watchFetch`. Every response is proof of connectivity and a new latency sample. No probes are sent. The app is online and its traffic does the measuring. 3. **The train enters a tunnel.** Radio coverage drops. If the OS notices, `navigator.onLine` flips to `false` and the `offline` event fires. The watcher goes offline at once, with no probe, and the banner shows "You are offline". If the OS does not notice, the next `fetch` fails with a `TypeError`, `watchFetch` calls `report(false)`, and the watcher sends a probe. The probe fails, so the watcher goes offline. 4. **Still in the tunnel.** The watcher retries on a timer: 2 seconds, then 4, 8, 16, and then every 30 seconds at most. Each retry sends a probe, unless `navigator.onLine` is still `false`, in which case the retry just confirms offline and sends nothing. Quality is `unknown` and `rtt` and `downlink` are `undefined`, because old numbers from before the tunnel would be misleading. 5. **The train comes out.** The `online` event fires, or the next retry succeeds. The watcher probes, gets an answer, and flips to `online: true`. The retry timer resets, and the banner shows "Back online" for a few seconds. 6. **Quality recovers.** The first fresh samples are slow, so quality starts wherever the first sample lands, often `fair` or `poor`, and improves as the smoothed RTT falls. It moves up a band only after the RTT is clearly below the boundary, so the banner does not flicker. | Moment | Signal | Requests sent | |---|---|---| | App opens | Startup suspect | 1 probe | | Browsing | `watchFetch` successes | 0 extra | | Tunnel | `offline` event or failed `fetch` | 0 or 1 probe | | In the tunnel | Retry timer | 1 probe after 2, 4, 8, 16, 30, 30 s and so on (none while `navigator.onLine` is `false`) | | Out of the tunnel | `online` event or next retry | 1 probe | ## Retry backoff while offline [#retry-backoff-while-offline] While the state is offline and a `pingUrl` exists, the watcher retries with exponential backoff. The first retry waits 2 seconds, each later wait doubles, and the wait is capped at `maxRetryDelay` (30 seconds by default). Going back online resets the delay to 2 seconds. Mobile Safari freezes timers in background tabs, so the retry timer cannot be the only way back. The watcher also re-checks when the tab becomes visible or a page is restored from the cache. ## The opt-in heartbeat [#the-opt-in-heartbeat] By default an online, idle app sends **zero requests**. That means a connection that dies while the user is reading will not be noticed until something else happens: a request fails, the OS flips `navigator.onLine`, or the user returns to the tab. If you need to catch that, set `heartbeat` to an interval in milliseconds. Every interval, the watcher sends a probe if it is online and the tab is visible. It skips hidden tabs, and it skips the offline state because backoff already covers it. The heartbeat also keeps `rtt` and `quality` fresh while the user is idle. It needs a `pingUrl`. ```ts import { createNetworkWatcher } from '@net-watch/core' const watcher = createNetworkWatcher({ pingUrl: '/api/ping', heartbeat: 60_000, // one probe a minute while online and visible }) ``` ## How quality is computed [#how-quality-is-computed] Every successful probe and every successful `watchFetch` request is a latency sample. The watcher keeps a smoothed average (an exponentially weighted moving average with weight 0.3 for the newest sample) and classifies it into a band. The state shows the average rounded to the nearest 50 ms. Download speed comes from a different source: the browser's Resource Timing entries for large files your page has already downloaded. The watcher computes throughput from them and smooths it the same way. No test files are fetched. On Chromium, `navigator.connection` adds `effectiveType`, `type` and `saveData`, and its own `rtt` and `downlink` estimates are fed in as extra samples. Other browsers leave `effectiveType` and `type` `undefined`, `saveData` is `false`, and `rtt` and `downlink` come from measurements only. [Network quality](/docs/concepts/network-quality) covers the bands, hysteresis and rounding in detail. ## Why state is immutable [#why-state-is-immutable] The state is a plain object. When something changes, the watcher builds a new object. When nothing changes, `getState()` returns the exact same reference. - **It plays well with frameworks.** React's `useSyncExternalStore`, Vue's `shallowRef` and Angular signals all compare by reference. A stable reference means no extra renders and no loops. - **Listeners get the previous state too.** `subscribe` callbacks receive `(state, prev)`, so you can compare without keeping your own copy. The watcher notifies **only on a visible change**. After rounding, if every field is equal to what it was, nobody is called. A stream of similar latency samples, with smoothed values of 128 ms, 131 ms and 126 ms, all round to 150 ms, so listeners hear about it once. When listeners do fire, `onOnline` or `onOffline` run first, then each subscriber in the order it subscribed. A callback that throws does not stop the others; the error is rethrown asynchronously so it still reaches your error reporting. ## What `destroy()` does [#what-destroy-does] `destroy()` stops everything the watcher started: - removes the `online`, `offline`, `pageshow`, `visibilitychange` and `navigator.connection` listeners - stops the Resource Timing observer - clears the heartbeat and the retry timer - drops every subscriber A probe that is already in flight finishes, but its result is discarded. Call `destroy()` when you create a watcher yourself and its owner goes away, for example in a test or a micro-frontend that unmounts. The `sharedWatcher()` that adapters use lives for the page and does not need it. ## Server-side rendering [#server-side-rendering] Without `window`, creating a watcher is safe and does nothing: the state is `online: true`, `quality: 'unknown'`, every metric `undefined`, and no listeners or timers exist. See the [SSR guide](/docs/guides/ssr). ## Next [#next] - [Detecting offline](/docs/concepts/detecting-offline): the decision rules in order. - [Network quality](/docs/concepts/network-quality): bands, hysteresis and metrics. - [API reference](/docs/reference/api): every option and method. # Network quality (/docs/concepts/network-quality) Online or offline is a yes or no. Quality is how usable the connection is. net-watch measures it from data the page already produces, and sends no test files. ## Where the numbers come from [#where-the-numbers-come-from] | Field | Source | |---|---| | `rtt` | Probe round-trip times, `report(true, rtt)` (which `watchFetch` calls with time to first byte), and Chromium's `navigator.connection.rtt` | | `quality` | A band over the smoothed `rtt` | | `downlink` | Resource Timing entries of files the page downloaded, plus Chromium's `navigator.connection.downlink` | | `effectiveType`, `type`, `saveData` | `navigator.connection`, Chromium only | A value the browser does not expose stays `undefined`. net-watch never estimates one from something unrelated. ## Latency: `rtt` [#latency-rtt] Each latency sample goes into an exponentially weighted moving average (EWMA): ``` avg = 0.7 * avg + 0.3 * sample ``` The first sample seeds the average. Later samples move it by 30 percent of the gap, so one slow response nudges the value and a sustained change moves it. Chromium's `rtt` estimate counts as a sample when it is available and non-zero (zero means "no estimate" and is skipped). The state shows the average **rounded to the nearest 50 ms**. Rounding is why a stream of similar samples does not wake your listeners: they are only called when a visible field changes. One consequence of using your own requests: `watchFetch` measures **time to first byte** of your own requests, because `fetch` resolves when headers arrive. If you wrap an endpoint that takes two seconds on the server, those two seconds count as latency, and `quality` can read `poor` on a perfectly good network. Keep that in mind when you wrap slow endpoints. See the [watchFetch guide](/docs/guides/watch-fetch). ## Quality bands [#quality-bands] `quality` classifies the smoothed RTT against three bounds. The defaults are `rttBounds: [150, 400, 1400]`. | Quality | Smoothed RTT | |---|---| | `excellent` | below 150 ms | | `good` | below 400 ms | | `fair` | below 1400 ms | | `poor` | 1400 ms or more | | `unknown` | No sample yet, or offline | Change the bounds with the `rttBounds` option. It is a tuple of three ascending numbers in milliseconds. A stricter app might use `[100, 250, 800]`. ```ts import { createNetworkWatcher } from '@net-watch/core' const watcher = createNetworkWatcher({ pingUrl: '/api/ping', rttBounds: [100, 250, 800], }) ``` ## Hysteresis [#hysteresis] A connection that hovers near a bound would flip between two bands on every sample, and a banner tied to `poor` would flicker. Hysteresis stops that with an asymmetric rule: - **Degrade immediately.** As soon as the smoothed RTT reaches a bound, quality moves to the worse band. - **Improve only below 0.8 times the bound.** To move back up, the RTT must fall clearly under the bound, not just touch it. Worked example with the default bounds. The band boundary between `good` and `fair` is 400 ms, so improving back to `good` requires below 0.8 x 400 = 320 ms. | Smoothed RTT | Quality | Why | |---|---|---| | 380 ms | `good` | Below 400 | | 420 ms | `fair` | Crossed 400, degrades at once | | 390 ms | `fair` | Below 400, but not below 320, so it stays | | 340 ms | `fair` | Still not below 320 | | 310 ms | `good` | Below 320, improves | The same rule applies at every bound: `excellent` returns below 120 ms, and `fair` returns from `poor` below 1120 ms. If the RTT falls far enough in one step, quality can jump more than one band. Going offline resets quality to `unknown`, so the first sample after coming back online sets the band directly, with no hysteresis. The band is computed from the unrounded average. The `rtt` you read is rounded to 50 ms, so do not recompute the band from it. ## Download speed: `downlink` [#download-speed-downlink] `downlink` is smoothed throughput in megabits per second, with the same EWMA. It is rounded to 0.1 Mbps below 10 and to whole Mbps from 10 up. net-watch does not download anything to measure it. It watches Resource Timing entries for resources the page loaded anyway, page-load assets included, and computes: ``` transferSize * 8 / (responseEnd - responseStart) / 1000 (Mbps) ``` An entry only counts when: - `transferSize` is at least **50,000 bytes**. Smaller responses are dominated by latency, not bandwidth. - It was not served from cache. Cached responses report a `transferSize` of 0. - It is not an opaque cross-origin response. Without a `Timing-Allow-Origin` header the browser reports 0. So `downlink` is `undefined` until your page has downloaded something large enough, such as a bundle, an image or an API payload. On Chromium, `navigator.connection.downlink` also feeds in as a sample, and gives a value early. Safari reports `transferSize` from version 16.4. Below that it reads 0, so no samples arrive and `downlink` stays `undefined` on Safari 15 to 16.3. ## The other fields [#the-other-fields] - **`effectiveType`**: `'slow-2g'`, `'2g'`, `'3g'` or `'4g'`, the browser's own classification. Chromium only. It is coarse and capped at `4g`, so use `quality` to tell good from great. - **`type`**: `'wifi'`, `'cellular'`, `'ethernet'` and so on. Only Chrome on Android and ChromeOS provide it. Everywhere else it is `undefined`. - **`saveData`**: `true` when the user turned on a data saver. Chromium only, and `false` in other browsers. Use it to skip heavy assets. These update when the browser fires a `change` event on `navigator.connection`. Firefox and Safari do not provide `navigator.connection`, so the three fields stay `undefined` (`saveData` stays `false`). ```ts const { quality, effectiveType, saveData } = watcher.getState() const lowBandwidth = saveData || quality === 'poor' || effectiveType === '2g' ``` ## What resets when you go offline [#what-resets-when-you-go-offline] When the watcher goes offline: - `quality` becomes `unknown` - `rtt` becomes `undefined` - `downlink` becomes `undefined` - the smoothed averages are thrown away, so old numbers never leak into the next session online - throughput samples are ignored until the watcher is back online `effectiveType`, `type` and `saveData` are not reset, because they describe what the browser reports about the link, not measurements. ## What the browser cannot tell a web page [#what-the-browser-cannot-tell-a-web-page] **Signal strength is not available to web pages.** There is no API for Wi-Fi bars or cellular dBm in any browser, and net-watch will not invent one. `effectiveType` and `rtt` describe how the connection performs, which is usually what you care about. If you need radio-level data, you need a native app. net-watch also never runs a bandwidth test. If you want one, run it yourself on user request. ## See also [#see-also] - [State reference](/docs/reference/state): every field with its type and browser support. - [Browser support](/docs/concepts/browser-support): which APIs each field depends on. # Why net-watch (/docs/concepts/why-net-watch) Most apps decide "is the user online?" with one line: ```ts if (navigator.onLine) { // go ahead and call the API } ``` That line answers a narrower question than the one you are asking. This page explains the gap, what net-watch does about it, and when you should not bother with it. ## What `navigator.onLine` really means [#what-navigatoronline-really-means] The browser sets `navigator.onLine` from the operating system's network state. It is a statement about the device, not about the internet. - **`false` is reliable.** No usable network interface is up, so no request can leave the machine. You can trust it, and net-watch does: `navigator.onLine === false` always wins. - **`true` only means an interface is up.** It says nothing about whether a request will arrive anywhere. `true` is wrong in several common situations: | Situation | `navigator.onLine` | What the user experiences | |---|---|---| | Wi-Fi connected, router has no uplink | `true` | Every request hangs or fails | | Captive portal (hotel, airport, café) | `true` | Requests are redirected to a login page, or TLS fails | | Virtual adapters from Docker, a VPN client or Hyper-V | `true`, even with Wi-Fi switched off | Nothing works, and the flag never flips | | Corporate firewall or proxy that blocks your host | `true` | Your API is unreachable while other sites load | Developers hit the virtual adapter case first. You turn Wi-Fi off to test your offline screen, nothing changes, and you conclude the feature is broken. Use DevTools, Network, Offline to test instead. The cost lands on your users. Buttons spin, forms submit into the void, and the app shows "Online" while nothing loads. ## Polling is the usual fix, and a poor one [#polling-is-the-usual-fix-and-a-poor-one] The common workaround is a timer: every N seconds, request a small URL and see whether it answers. - **It sends traffic all the time.** Every open tab of every user sends a request every N seconds, forever, even while nothing is happening. That wakes the radio on phones, costs battery and adds load on your server. - **It forces a bad trade-off.** A short interval catches outages quickly but multiplies the traffic. A long interval is cheap but leaves the app wrong for up to N seconds. - **It ignores evidence you already have.** Your app is making real requests. A response is stronger proof of connectivity than any ping. ## What net-watch does instead [#what-net-watch-does-instead] net-watch layers its evidence and spends the cheap signals first. 1. **Browser signals.** `navigator.onLine`, the `online` and `offline` events, the tab becoming visible, a page restored from the back/forward cache. They cost nothing. 2. **Your real requests.** Wrap `fetch` with `watchFetch` and every response becomes proof that the user is online, plus a latency sample. A network error becomes a suspicion. 3. **A probe, only when needed.** When something looks wrong, or while the user is offline, net-watch sends one `HEAD` request to your `pingUrl`. It never sends more than one at a time. While the user is offline, probes back off: 2 seconds, then doubling up to 30 seconds. The wait resets once the connection is back. The result is that **an online, idle app sends no requests** apart from a check at startup and one whenever the browser signals a change, such as the tab becoming visible again. If you want to notice a connection that dies while the user is idle, opt in to a `heartbeat` interval. It is off by default. Without a `pingUrl`, only layers 1 and 2 run. That still catches unplugged cables and Wi-Fi switched off, and a successful request proves the user is online. Without a probe nothing can confirm a failed request, so it cannot catch "connected to Wi-Fi but no internet". The [ping endpoint guide](/docs/guides/ping-endpoint) adds that in a few lines. The full decision logic is in [Detecting offline](/docs/concepts/detecting-offline). ## The quality side [#the-quality-side] "Online" is not one thing. A user on a congested train network is online and still cannot use your video upload. net-watch also reports how good the link is, from data it already has: - **Latency (`rtt`).** Round-trip times from probes and from your own requests, smoothed and rounded to 50 ms so the state does not change on every sample. - **A quality band.** `excellent`, `good`, `fair` or `poor`, derived from the smoothed latency, with a small hysteresis so the band does not flicker at a boundary. - **Download speed (`downlink`).** Computed from the browser's own Resource Timing entries for files your page already downloaded. Only large responses count, because on a tiny response latency dominates the measurement. - **Browser hints.** `effectiveType`, `type` and `saveData` where the browser exposes them (Chromium). Nothing here downloads test files. The details are in [Network quality](/docs/concepts/network-quality). ## What it deliberately does not do [#what-it-deliberately-does-not-do] - **Signal strength.** Wi-Fi bars and cellular dBm are not available to web pages in any browser. net-watch does not fake them. A native app can read them; a web page cannot. - **Bandwidth tests.** It never downloads a payload to measure speed. That would burn the user's data and could make a poor connection worse. If the page has not downloaded anything large, `downlink` stays `undefined`. - **Guessing.** A metric the browser does not expose is `undefined`, not an estimate built from something unrelated. ## How it compares [#how-it-compares] These are the alternatives most people find first, as of October 2026. | Library | Scope | Notes | |---|---|---| | VueUse `useNetwork` / `useOnline`, react-use `useNetworkState` and similar hooks | Vue, React and others | Built on `navigator.onLine`, the online/offline events and `navigator.connection`. No check that a server is reachable, no banner | | `offline-js` (Offline.js) | Any page | Bundles a banner and pings. Last npm release is 0.7.19 from 2017, no TypeScript types, no framework adapters | | `react-detect-offline` | React only | Last published in 2022 | | `ng-connection-service` | Angular only | Last published 2024 | | `is-online` | Node-oriented | One-time check, no events or banner | | `@react-native-community/netinfo` | React Native only | Reads the native network stack | The hook libraries do what they say, and if `navigator.onLine` is enough for you, they are enough. net-watch exists for the case where it is not: it checks that a server is reachable, reports quality, and ships a banner, in one typed core with thin adapters for React, Vue and Angular. ## When not to use net-watch [#when-not-to-use-net-watch] - **You only need to know that Wi-Fi was switched off.** `navigator.onLine` and the two events already do that. Adding a library buys you nothing. - **You are building for React Native.** net-watch targets browsers. Use `@react-native-community/netinfo`, which can read the native network stack. - **You need signal strength or a speed test.** A web page cannot read the first, and net-watch deliberately does not run the second. If none of those apply, start with the [quick start](/docs/quick-start). # Angular (/docs/frameworks/angular) `@net-watch/angular` exports one function, `injectNetworkStatus`. It returns a read-only `Signal` that updates when the connection state changes. It needs Angular 17 or later. ## Install [#install] ```package-install npm i @net-watch/core @net-watch/angular ``` ## Use it in a component [#use-it-in-a-component] ```ts title="status.component.ts" import { Component } from '@angular/core' import { injectNetworkStatus } from '@net-watch/angular' import { watcher } from './network' @Component({ selector: 'app-status', template: ` @if (status().online) {

Online, {{ status().quality }} ({{ status().rtt ?? '?' }} ms)

} @else {

Offline

} `, }) export class StatusComponent { status = injectNetworkStatus(watcher) } ``` `status` is a signal, so call it: `status()`. It is read-only, so there is no `set` or `update`. ## Injection context [#injection-context] `injectNetworkStatus` calls `inject(DestroyRef)`, so it must run in an injection context: a constructor, a field initializer (as above), or a function you run with `runInInjectionContext`. Calling it from a method, a lifecycle hook or a timer throws. It subscribes to the watcher when called and unsubscribes through `DestroyRef` when the component (or the injector that owns it) is destroyed. ## Pass your own watcher [#pass-your-own-watcher] The signature is `injectNetworkStatus(watcher?)`. Without an argument it uses `sharedWatcher()` from `@net-watch/core`, which has no `pingUrl`. Without a `pingUrl`, net-watch cannot see "connected but no internet" (see [the ping endpoint guide](/docs/guides/ping-endpoint)). To configure the watcher, create it once in a module and pass it in, as above: ```ts title="network.ts" import { createNetworkWatcher } from '@net-watch/core' export const watcher = createNetworkWatcher({ pingUrl: '/api/ping' }) ``` ## Derived values [#derived-values] Use `computed()` to derive values from the signal: ```ts import { Component, computed } from '@angular/core' import { injectNetworkStatus } from '@net-watch/angular' import { watcher } from './network' @Component({ selector: 'app-quality', template: `{{ label() }}`, }) export class QualityComponent { private status = injectNetworkStatus(watcher) label = computed(() => { const { online, quality, rtt } = this.status() if (!online) return 'Offline' return rtt === undefined ? 'Measuring' : `${quality} (${rtt} ms)` }) } ``` `rtt` is `undefined` until the first sample, and the code shows a placeholder instead of a guess. ## Provide the watcher through DI [#provide-the-watcher-through-di] A module-level `watcher` is the simplest option. If you prefer dependency injection, for example to replace the watcher in tests, provide it through an injection token: ```ts title="network.token.ts" import { InjectionToken } from '@angular/core' import { createNetworkWatcher, type NetworkWatcher } from '@net-watch/core' export const NETWORK_WATCHER = new InjectionToken('NETWORK_WATCHER', { providedIn: 'root', factory: () => createNetworkWatcher({ pingUrl: '/api/ping' }), }) ``` ```ts title="status.component.ts" import { Component, inject } from '@angular/core' import { injectNetworkStatus } from '@net-watch/angular' import { NETWORK_WATCHER } from './network.token' @Component({ selector: 'app-status', template: `

{{ status().online ? 'Online' : 'Offline' }}

`, }) export class StatusComponent { status = injectNetworkStatus(inject(NETWORK_WATCHER)) } ``` A service works the same way: create the watcher in an `@Injectable({ providedIn: 'root' })` class and pass it to `injectNetworkStatus` from a component. In tests, provide a different value for `NETWORK_WATCHER`. The root watcher lives as long as the app. If you create one with a shorter life, call its `destroy()` yourself. ## SSR [#ssr] On the server, `injectNetworkStatus` reads the fixed server state (`online: true`, `quality: 'unknown'`, metrics `undefined`). In the browser it reads the real state, which on Chromium may already include `rtt` and `quality`. Render quality-dependent UI after the first browser render, and create the banner in `afterNextRender` (a developer preview API before Angular 20). The [SSR guide](/docs/guides/ssr#angular) shows both. ## Banner [#banner] `createBanner` is plain DOM. See [the banner guide](/docs/guides/banner) for options and theming, and [SSR](/docs/guides/ssr#the-banner-needs-the-dom) for an Angular component that creates it safely. ## Next [#next] - [`watchFetch`](/docs/guides/watch-fetch): wrap your requests so the state stays fresh without extra probes. - [State reference](/docs/reference/state): every field and where it is available. # React (/docs/frameworks/react) `@net-watch/react` exports one hook, `useNetworkStatus`. It returns the current `NetworkState` and re-renders your component when the state changes. It needs React 18 or later. ## Install [#install] ```package-install npm i @net-watch/core @net-watch/react ``` ## Minimal component [#minimal-component] ```tsx title="Status.tsx" import { useNetworkStatus } from '@net-watch/react' export function Status() { const { online, quality } = useNetworkStatus() return

{online ? `Online, ${quality}` : 'Offline'}

} ``` Called without an argument, the hook uses the shared default watcher. ## Pass your own watcher [#pass-your-own-watcher] The signature is `useNetworkStatus(watcher?)`. The default, `sharedWatcher()` from `@net-watch/core`, is created on first use with default options, which means no `pingUrl`. Without a `pingUrl`, net-watch cannot see "connected but no internet" (see [the ping endpoint guide](/docs/guides/ping-endpoint)). To configure the watcher, create it once in a module and pass it in: ```ts title="network.ts" import { createNetworkWatcher } from '@net-watch/core' export const watcher = createNetworkWatcher({ pingUrl: '/api/ping' }) ``` ```tsx title="Status.tsx" import { useNetworkStatus } from '@net-watch/react' import { watcher } from './network' export function Status() { const { online, quality } = useNetworkStatus(watcher) return

{online ? `Online, ${quality}` : 'Offline'}

} ``` Use the same watcher everywhere: pass it to the hook, to `watchFetch` and to `createBanner`. Two watchers would send two sets of probes and could disagree. ## Why useSyncExternalStore [#why-usesyncexternalstore] The hook is a single call to React's `useSyncExternalStore`, which is the built-in way to read a store that lives outside React. It avoids tearing (two components showing different values in one render) and works with concurrent rendering. It needs `getState()` to return the same object until something changes, and net-watch guarantees that: a change replaces the state object, and no change keeps the same reference. Without that guarantee React would see a new value on every read and loop. ### Server snapshot [#server-snapshot] On the server, and during hydration, the hook returns a fixed state: `online: true`, `quality: 'unknown'`, no metrics, `saveData: false`. React then switches to the real state after hydrating, so the server HTML always matches the first client render. See [SSR](/docs/guides/ssr). ## Re-renders [#re-renders] The hook returns the whole state object. A component using it re-renders only when something visible changes: `online`, `quality`, `rtt` (rounded to 50 ms), `downlink` (rounded to 0.1 Mbps below 10 Mbps, to whole Mbps above), `effectiveType`, `type` or `saveData`. A stream of similar latency samples does not cause renders. React has no selector in this hook. If a component reads only `online` and you want it to skip `rtt` updates, keep it small and put it in a leaf component, or subscribe to a single field yourself: ```tsx title="OnlineDot.tsx" import { useSyncExternalStore } from 'react' import { watcher } from './network' export function OnlineDot() { const online = useSyncExternalStore( watcher.subscribe, () => watcher.getState().online, () => true, // server snapshot: the server state is always online ) return {online ? '●' : '○'} } ``` ## A banner component [#a-banner-component] `createBanner` is plain DOM. Create it in an effect and destroy it on cleanup: ```tsx title="OfflineBanner.tsx" import { useEffect } from 'react' import { createBanner } from '@net-watch/core/banner' import { watcher } from './network' export function OfflineBanner() { useEffect(() => { const banner = createBanner(watcher, { slowText: 'Slow connection' }) return () => banner.destroy() }, []) return null } ``` Render it once, near the root. See [the banner guide](/docs/guides/banner) for options and theming. ## Show quality with rtt [#show-quality-with-rtt] `quality` is `'excellent' | 'good' | 'fair' | 'poor' | 'unknown'`, and `rtt` is `undefined` until the first sample. Show a placeholder, and do not guess a value. ```tsx title="ConnectionBadge.tsx" import { useNetworkStatus } from '@net-watch/react' import type { Quality } from '@net-watch/core' import { watcher } from './network' const labels: Record = { excellent: 'Excellent', good: 'Good', fair: 'Fair', poor: 'Poor', unknown: 'Measuring', } export function ConnectionBadge() { const { online, quality, rtt } = useNetworkStatus(watcher) if (!online) return Offline return ( {labels[quality]} {rtt !== undefined && ` (${rtt} ms)`} ) } ``` ## Next.js [#nextjs] Hooks only work in client components, so add `'use client'` to every file that calls `useNetworkStatus` or creates a banner. Import the watcher only from client components. ```tsx title="components/status.tsx" 'use client' import { useNetworkStatus } from '@net-watch/react' import { watcher } from '@/lib/network' export function Status() { const { online } = useNetworkStatus(watcher) return

{online ? 'Online' : 'Offline'}

} ``` Server components can render ``. The [SSR guide](/docs/guides/ssr#nextjs) has the full setup. ## Next [#next] - [`watchFetch`](/docs/guides/watch-fetch): wrap your requests so the state stays fresh without extra probes. - [State reference](/docs/reference/state): every field and where it is available. # Vue (/docs/frameworks/vue) `@net-watch/vue` exports one composable, `useNetworkStatus`. It returns a `ComputedRef` that updates when the connection state changes. It needs Vue 3.3 or later. ## Install [#install] ```package-install npm i @net-watch/core @net-watch/vue ``` ## Use it in a component [#use-it-in-a-component] ```vue title="Status.vue" ``` `status` is a computed ref. In ` ``` See [the banner guide](/docs/guides/banner) for options and theming. ## Nuxt [#nuxt] Create the watcher in a plain module, such as `utils/network.ts`, and import it from components. Importing and creating a watcher on the server is safe: it returns a fixed state (`online: true`, `quality: 'unknown'`) and starts no listeners or timers. On Chromium, the first client render can differ from the server render, because `navigator.connection` may already hold values when the watcher is created. Render quality-dependent UI after mount, or wrap it in ``. Create the banner in `onMounted`, which Nuxt runs only in the browser. See [SSR](/docs/guides/ssr#vue-and-nuxt). ## Next [#next] - [`watchFetch`](/docs/guides/watch-fetch): wrap your requests so the state stays fresh without extra probes. - [State reference](/docs/reference/state): every field and where it is available. # Offline banner (/docs/guides/banner) `createBanner` adds a small floating card to the page that follows the watcher. It is plain DOM, so it works in any framework, and it does not need an adapter. ```ts import { createBanner } from '@net-watch/core/banner' import { watcher } from './network' const banner = createBanner(watcher, { slowText: 'Slow connection' }) // later, for example when your app unmounts banner.destroy() ``` ## What it shows [#what-it-shows] | State | When | How long it stays | |---|---|---| | Offline | The watcher reports `online: false`. | Until the user is back online. | | Slow | The user is online, `quality` is `poor`, and you set `slowText`. | While `quality` stays `poor`. Leaving `poor` hides it at once. | | Online | The user came back after being offline. | Shows `onlineText`, then hides after `hideAfter` ms. | The banner stays hidden while everything is fine. It does not announce "online" when the page first loads. If the page loads while the user is already offline (or already `poor` with `slowText` set), the banner shows right away. Without `slowText`, the slow state never appears. That keeps the default quiet: a `poor` connection is not a failure, and not every app wants to say so. ## Options [#options] All options are optional. | Option | Default | What it does | |---|---|---| | `offlineText` | `'You are offline'` | Text for the offline state. | | `onlineText` | `'Back online'` | Text shown after the connection returns. | | `slowText` | unset | Text for the slow state. Leave it unset to never show that state. | | `theme` | `'auto'` | `'auto'` follows `prefers-color-scheme`. `'light'` and `'dark'` force a palette. | | `position` | `'bottom'` | `'top'` or `'bottom'`. Respects the device safe area (the notch and the home indicator). | | `hideAfter` | `3000` | How many ms the back-online message stays visible. | | `container` | `document.body` | The element the banner is appended to. | ```ts createBanner(watcher, { offlineText: 'No connection. Changes will sync later.', onlineText: 'Connected again', slowText: 'Slow connection', position: 'top', hideAfter: 2000, }) ``` ## The handle [#the-handle] `createBanner` returns an object with two methods: - `destroy()` removes the banner element and unsubscribes from the watcher. Call it when your app or component goes away. The shared `