Skip to content

Offline

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

Updated View as Markdown
'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:

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

vite.config.tsts
rscKit({ offline: true })

The build writes sw.js beside the assets and the generated entry registers it. 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.

What is cached, and when

hashed assets at install, and answered 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.

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

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

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:

src/app/offline/page.tsxtsx
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 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:

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

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

Going further

Installing, push notifications and background sync are in Progressive web apps.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close