# Offline

> Knowing when the server cannot be reached, and carrying on without it.

```tsx
'use client'

import { useOffline } from '@rsc-kit/core/useOffline'

export function ConnectionBanner() {
  const offline = useOffline()

  if (!offline) return null

  return <p role="status">You are offline. Changes will not be saved.</p>
}
```

## What "online" means here

Whether the **router could reach the server**, as of its last attempt — not
`navigator.onLine`.

That distinction matters. `navigator.onLine` only tells you the network
interface is up — a laptop on café wifi that needs a login is "online" by that
measure and can reach nothing.

This reports what the router actually observed on its own requests, which is
what a person means by offline.

It follows that the value only changes when something is attempted. A tab
sitting idle with a dead connection reports online until the next navigation,
prefetch or action tries and fails.

## On the server

`useOffline()` returns `false` during a server render, and deliberately: the
server can reach itself. Returning anything else would render an offline state
into the HTML and then mismatch when the browser hydrated and disagreed.

## `useOnline`

The same reading, the other way round, for when that is what the markup wants:

```tsx
'use client';

import { useOnline } from '@rsc-kit/core/useOnline';

export function Status() {
  return <span>{useOnline() ? 'connected' : 'reconnecting…'}</span>;
}
```

It returns `true` on the server, for the same reason `useOffline()` returns
`false` there. Both read the one store, so they cannot disagree.

## Working with no network at all

The hooks above report. A service worker is what lets the app keep working,
and it is off by default:

```ts title="vite.config.ts"
rscKit({ offline: true })
```

The build writes `sw.js` beside the assets and the generated entry registers
it — once the page has loaded, or at once if it already has by the time the
runtime boots. With it on, a page you have visited survives a full reload
with no network at all — not just a navigation, a reload — and comes back
interactive.

Everything else lives in one page's memory — the pages a boundary keeps
mounted, the prefetch cache. Reload with no network and the browser shows its
own error page: no script runs, so nothing held in JavaScript is reachable.

A service worker is the only thing that survives that.

Nothing of it runs in development. A worker is registered per origin, and one
a production run left on `localhost:3000` would otherwise sit in front of the
dev server on the same port — a stored page is cache-first, so a refresh would
bring back yesterday's document and a `504 Outdated Optimize Dep` for each of
its modules. The dev server answers `/sw.js` with a worker that removes the old
one, its caches, and reloads the pages it held; the browser asks for that file
on every navigation, so it happens on the first load.

### What is cached, and when

| | |
| --- | --- |
| what boots the app | at install: the scripts, stylesheets, fonts, manifest and icons, plus `/` and the offline page with the payloads they boot from. Not an image, a wasm module or the share card — those are cached the first time they are used, so an install costs what a first page costs and not a megabyte more |
| any other hashed asset | the first time it is asked for, and from the cache forever after — the name changes when the bytes do |
| a page you loaded | its document, and the payload it boots from |
| a page you reached by link | its payload, and its document fetched once to go with it |
| a page you never visited | nothing |

The last two rows are the same mechanism from either end. A hard load fetches a
document and never the payload; a link fetches a payload and never the document.
Either alone is half a page.

So whichever arrives first fetches the other, **once per url**. A page costs one
extra request the first time you reach it, and none after.

A url you have never opened cannot be served, and is not faked. An earlier
version answered with the cached root, which put the home page's markup under
the address someone asked for and did not hydrate — a wrong page pretending to
be the right one. It fails now, which is true.

### When a deploy changes a stored page

The worker's version is a hash of everything it serves cache-first: the
precached files and the pages the build stored. A deploy that changes a
stored page is a new worker, which sweeps the old copies. The version used to
cover the file names alone, so a deploy that changed a stored page and no
client script left the worker as it was, and the first load after it showed
the previous build's page from the cache.

An app that writes the build time into a stored page gets a new worker on
every deploy, because its stored pages really do differ each time.

### What is deliberately not cached

Anything the server sent with `Cache-Control: no-store`, whatever else it is.

The Cache API files responses by url and knows nothing about who asked. So a
guarded page, or a [query](/guides/queries/) that read the session, would be
handed to whoever opens the app next on that machine — signed in as someone
else. `no-store` is the server saying so, and the worker obeys it.

This is why a query does **not** work offline by default. Queries answer
`private, no-store` precisely because they may read the session, and widening
that is a decision about who may see the answer:

```ts
// Cacheable, and offline, because the answer is the same for everyone.
export const getPricing = query(async () => tiers(), { cache: "private", maxAge: 300 })
```

For a personal read you want across reloads, do not widen it — persist your
cache library instead. TanStack's `persistQueryClient` writes to storage that
belongs to that browser, which gets you the same result without putting one
visitor's data somewhere the next one can be served it.

### Updating

The cache name is a hash of what it holds. A build that changed nothing keeps
the same name and leaves a visitor's cache alone; a build that changed anything
gets a new one, and the new worker sweeps the old caches away.

There is no version to set, and no way to strand someone on a stale worker.

A new worker takes over as soon as it installs rather than waiting for every
tab to close. That is safe here because assets are addressed by content: a page
already open goes on asking for the names it was built with.

<Aside type="note" title="The first visit still needs a reload">
  A worker does not control the page that registered it. So the first visit
  caches what it needs and the *second* is the one that survives being offline.
  That is how service workers work everywhere, not something particular here.

  Development is left alone entirely — a worker answering from a cache in front
  of the dev server turns every edit into a question about which copy you are
  looking at.
</Aside>

<Aside type="caution" title="It can hide a server that is down">
  Serving from a cache asks nothing of the network, so an app can look healthy
  while its backend is not. That is the same edge the reveal window has, and it
  is what `useOffline()` above is for: with this on, saying so in the interface
  stops being a nicety.
</Aside>

## When nothing can answer

A reload with no network, on a page the worker has never cached, fails. It does
not fall back to the cached home page — that was tried, and it was worse than
failing: the document *is* the page here, so the visitor got the home page's
markup under the address they asked for, and it did not hydrate.

Add a route at `/offline` and that page is served instead:

```tsx title="src/app/offline/page.tsx"
export default function Offline() {
  return (
    <main>
      <h1>You are offline</h1>
      <p>Try again once you are back.</p>
    </main>
  )
}
```

Nothing in the file makes it special. It is an ordinary route, and what makes it
the fallback is that the build stored it and the worker precached it. It is also
the one page that can honestly stand in for another, because it is about being
offline rather than about the url it appears under. It stands in for every
navigation nothing can answer — a page the build stored but this browser
never visited included.

**It has to be static.** A fallback that renders per request cannot be served
when there is no request to be made, so the build checks and says when it will
not work:

```text
[rsc-kit] offline: /offline cannot be the fallback, because it called cookies(),
          headers(). A fallback has to be servable with no network at all.
```

It is served for navigations only — a payload request answered with a document
would be handed to the Flight decoder, which throws.

## When a new version is live

The worker takes over as soon as it installs rather than waiting for every tab
to close, and taking over sweeps the previous build's cache. A page that has
been open across a deploy is therefore running javascript whose remaining chunks
are gone. It works until it navigates somewhere that needs one.

Only the worker knows this happened, so it says so:

```tsx
'use client'
import { useAppUpdate } from '@rsc-kit/core/useAppUpdate'

export function UpdateBanner() {
  const { updated, reload } = useAppUpdate()

  if (!updated) return null

  return <button onClick={reload}>A new version is ready — reload</button>
}
```

Reported rather than acted on. Reloading out from under someone mid-form is
worse than the staleness it fixes, so this package will not do it for you.

What it does do is stop navigating as if nothing had changed. Once the
worker has said a newer build is live, the next navigation is a document
load, from the new build — the page's own javascript cannot load what the
new build's payloads name. And whether or not a worker said anything, every
document says which build it is, the client says it back on every
navigation, and a server on another build answers `409` and the client
loads the document. Under a worker that is not a window of seconds: the
worker serves the previous build's document first, from its cache, while
the new one installs, so it is every returning visitor's first navigation
after a deploy — and without this it ended on the route's error boundary
with "client reference not found".

## Going further

Installing, push notifications and background sync are in
[Progressive web apps](/guides/pwa/).
