
Next.js Handling connectivity drops
Before this feature existed, a network drop mid-navigation or mid-Server-Action was just an error. The fetch throws, the promise rejects, and it's on you to catch it, decide what to show, and figure out a retry strategy — usually some ad hoc combination of try/catch, a "something went wrong" banner, and a manual retry button that may or may not actually work depending on whether the failure was transient.
Next.js 16 ships an experimental alternative: experimental.useOffline, which changes what happens when the network disappears mid-request. Instead of throwing, the request just... waits. When connectivity returns, Next.js retries it automatically, and your component never sees an error at all — it just sees a request that took an unusually long time. This article covers how that works, how to build UI that's honest about connectivity state, and where the feature's boundaries are.
Worth flagging up front, in the framework's own words: this is experimental and explicitly not recommended for production yet. Everything below is worth understanding and experimenting with, but treat it as a preview of where things are headed rather than something to ship to real users today.
The core behavior change
With the flag off, a failed navigation, RSC data fetch, prefetch, or Server Action throws immediately when the network is down — that's the behavior every Next.js app has had until now. With experimental.useOffline enabled, that same failure no longer throws. Next.js keeps the request pending and retries it once the connection returns.
From the UI's perspective, this looks exactly like a slow server — the request just sits in its loading state (a Suspense fallback, or a pending transition for a Server Action) for however long the outage lasts. That's a deliberate design choice: rather than inventing a whole new UI state category for "offline," Next.js reuses the loading state you already have, and gives you a hook — useOffline — to optionally make that loading state connectivity-aware if you want to.
One boundary worth understanding immediately: this only covers requests Next.js itself manages — soft navigations, RSC fetches, prefetches, and Server Actions. Anything you fetch yourself directly inside a Client Component with raw fetch(), or through a library like SWR or React Query, stays entirely under that library's own retry policy. This feature doesn't reach into arbitrary client-side data fetching; it's specifically about the App Router's own request machinery.
Setting it up
The example the docs build — and the one worth walking through, because it demonstrates the two supporting pieces this feature leans on — is a live-metrics dashboard: a page that fetches fresh, uncached data on every request, plus a form that pings a Server Action.
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
experimental: {
useOffline: true,
},
};
export default nextConfig;
Cache Components and Partial Prefetching aren't strictly required by useOffline itself, but they're what make the offline navigation case actually useful rather than merely non-throwing. Cache Components lets you place a <Suspense> boundary right up against your uncached data, with the App Shell rendered around it. Partial Prefetching is what makes that App Shell the actual unit a <Link> prefetches — so it's already sitting in the browser, ready to render, before the user ever goes offline.
// app/page.tsx
import Link from "next/link";
export default function Home() {
return (
<nav>
<Link href="/dashboard">Dashboard</Link>
</nav>
);
}
// app/dashboard/page.tsx
import { Suspense } from "react";
import { getLiveMetrics } from "../lib/data";
export default function Dashboard() {
return (
<section>
<h1>Live metrics</h1>
<Suspense fallback={<p>Loading...</p>}>
<MetricsTable />
</Suspense>
</section>
);
}
async function MetricsTable() {
const { services } = await getLiveMetrics();
// render services
}
With the <Link> visible on the home page, its target's App Shell gets prefetched automatically. Go offline, click through to /dashboard: the title and container — the static shell — render immediately, because they were already sitting in the browser from the prefetch. The Loading... fallback stays up indefinitely, because the uncached getLiveMetrics() call genuinely can't complete without a network. Restore connectivity, and the metrics table streams in with no additional code — Next.js retried the request on its own.
If you're not using Cache Components yet, a plain route-level loading.tsx gets you the equivalent behavior at the segment level — the shell/fallback split just happens at a coarser granularity than a hand-placed <Suspense> boundary would give you.
One hard limit worth internalizing immediately: this only covers soft navigations into already-prefetched routes, and Server Action calls from the current page. A full page reload while offline still fails outright, because the browser genuinely needs the network to fetch the HTML document itself — there's no service worker in this picture intercepting that request. If you need actual offline-first page loads (not just resilience to a mid-session drop), that's a different, heavier tool: a service worker, covered in the Progressive Web Apps guide.
Making the loading state honest about connectivity
The generic Loading... fallback above isn't wrong, but it's also indistinguishable from "the server is just slow" — which is a real UX gap if an outage lasts more than a few seconds and the user has no idea whether to wait or refresh. The useOffline hook exists specifically to close that gap:
// app/dashboard/connectivity-fallback.tsx
"use client";
import { useOffline } from "next/offline";
export function ConnectivityFallback() {
const isOffline = useOffline();
return (
<p>
{isOffline
? "Waiting for connection to load this section..."
: "Loading..."}
</p>
);
}
// app/dashboard/page.tsx
import { Suspense } from "react";
import { getLiveMetrics } from "../lib/data";
import { ConnectivityFallback } from "./connectivity-fallback";
export default function Dashboard() {
return (
<section>
<h1>Live metrics</h1>
<Suspense fallback={<ConnectivityFallback />}>
<MetricsTable />
</Suspense>
</section>
);
}
useOffline is genuinely more trustworthy here than reaching for the browser's native navigator.onLine yourself — that API only reflects whether the OS's network interface is up, and reports true even when a device is connected to WiFi with no actual upstream internet. useOffline instead flips to true either when the browser fires a real offline event, or when an actual navigation, prefetch, or Server Action request fails — and flips back to false only once a background connectivity check genuinely succeeds. It's measuring "can I actually talk to the server," not "does the network interface report as up."
One detail worth knowing so it doesn't surprise you: useOffline returns false during server-side rendering and initial hydration, unconditionally — there's no way to know real connectivity state before the app has mounted in the browser. The first value you can actually trust is whatever it resolves to after mount.
A global connectivity banner
The per-fallback approach above is scoped — it only shows on the page where you added it, and only while that specific boundary is waiting. For app-wide visibility, a small banner in the root layout is the more common pattern:
// app/offline-banner.tsx
"use client";
import { useOffline } from "next/offline";
export function OfflineBanner() {
const isOffline = useOffline();
if (!isOffline) {
return null;
}
return (
<div role="status">
Offline. Pending requests will retry once you are back online.
</div>
);
}
// app/layout.tsx
import { OfflineBanner } from "./offline-banner";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
<OfflineBanner />
{children}
</body>
</html>
);
}
There's no real conflict between having both — the banner covers "something is wrong, app-wide," and a per-fallback message covers "this specific section is what's actually waiting." Most apps are probably fine shipping just the banner; add per-fallback messaging selectively, on whichever sections genuinely benefit from more specific feedback.
The same pattern extends cleanly to dynamic routes: navigating to /chats/42 offline still renders the shared App Shell for /chats/[id], with the specific chat's messages behind a <Suspense> boundary streaming in once connectivity returns — and if that route also does per-link prefetching of its URL-specific data (covered in the companion "Optimizing prefetching" article), the messages can render immediately from that prefetch even while offline, without waiting for the connection at all.
Server Actions: no try/catch, no retry loop
The same non-throwing behavior extends to Server Actions, and this is arguably the more valuable half of the feature, since form submissions failing silently mid-flight is a genuinely common and annoying failure mode in ordinary apps.
// app/ping/actions.ts
"use server";
export async function ping(): Promise<string> {
return new Date().toISOString();
}
// app/ping/ping-form.tsx
"use client";
import { useState, useTransition } from "react";
import { useOffline } from "next/offline";
import { ping } from "./actions";
export function PingForm() {
const [pongs, setPongs] = useState<string[]>([]);
const [pending, startTransition] = useTransition();
const isOffline = useOffline();
function handleSubmit() {
startTransition(async () => {
const pong = await ping();
setPongs((prev) => [pong, ...prev]);
});
}
const label = pending
? isOffline
? "Pinging (offline, will retry)..."
: "Pinging..."
: "Ping";
return (
<form action={handleSubmit}>
<button type="submit" disabled={pending}>
{label}
</button>
<ul>
{pongs.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
</form>
);
}
Notice what's not in this component: no try/catch around the awaited call, no manual retry logic, no reconnection listener. Click "Ping" while offline, and the button disables and its label flips to "Pinging (offline, will retry)..." — the awaited ping() call simply doesn't resolve yet. The moment connectivity returns, Next.js retries the underlying request transparently, the promise resolves with the server's actual response, the timestamp gets appended to the list, and the label reverts to "Ping." From the component's point of view, this looks exactly like a Server Action that happened to take a very long time — because, from its perspective, that's precisely what happened.
One interaction worth knowing about ahead of time: clicking a link while a Server Action is still pending offline can appear to do nothing. That's not a bug — the link's own navigation also needs the network, and it queues behind the same connectivity signal as the pending action. Both resolve together once the connection returns; there's just no visual indication that the click "landed" until then, which is worth accounting for in your loading UI if this is a pattern your users are likely to hit.
Testing this properly
This is one of those features where dev mode actively lies to you. Test with next build && next start, not the dev server — the docs are explicit that dev mode isn't a reliable reference for offline behavior, likely because of how differently requests are handled in development (HMR, on-demand compilation) versus a production build.
For simulating the actual outage: Chrome's DevTools has a dedicated Network → Offline toggle; Firefox's Network Monitor has an equivalent throttling menu. For something closer to a real-world test, actually toggling airplane mode on a phone or laptop, or physically pulling an ethernet cable, exercises paths that DevTools' simulated offline mode sometimes doesn't (OS-level network state changes behave slightly differently from a browser-level override).
Without Cache Components
If you haven't adopted Cache Components yet, you're not locked out of this feature — a route-level loading.tsx does the same essential job, just at a coarser boundary than a hand-placed <Suspense>. It gives Next.js a shell to prefetch for the route, so that shell is what renders while offline, and the page resumes once connectivity returns. The useOffline hook, the banner pattern, and Server Action retry behavior are all identical either way — Cache Components just lets you be more surgical about exactly which piece of the page counts as "shell" versus "waits for data."
Key Takeaways
| Question | Answer |
|---|---|
| Status | Experimental — not recommended for production yet |
| What changes | Failed navigations, RSC fetches, prefetches, and Server Actions no longer throw — they wait and retry |
| What it doesn't cover | Raw fetch() in Client Components, SWR/React Query requests, full page reloads |
| Detecting offline state | useOffline() hook — more reliable than navigator.onLine |
| Where to test | next build && next start, never dev mode |
| Pairs well with | Cache Components + Partial Prefetching (for shell prefetching), Progressive Web Apps guide (for true offline page loads) |
The value of this feature isn't that it makes bad networks fast — nothing can do that. It's that it removes an entire category of error-handling code (try/catch, retry loops, reconnection listeners) from your components by making "wait and retry" the framework's default behavior for its own request machinery, and gives you useOffline as the one hook you need if you want your loading states to be honest about why they're taking so long.


