Offline Fallback Pages
When a user loses connectivity mid-session and taps a link, the browser fires a navigation request that the network can no longer answer. Left unhandled, the service worker’s fetch promise rejects and the browser paints its own generic dinosaur or “no internet” page — an abrupt exit from your application’s shell, styling, and brand. An offline fallback page is the deliberate alternative: a precached HTML document you serve from the service worker the moment a navigation fetch fails, so the reader stays inside a page you designed rather than one the browser improvised. This guide covers how to precache that fallback during install, how to wire the catch() branch of a navigation handler, when a single fallback beats a per-route one, and how to make the offline state genuinely accessible.
← Back to Service Worker & Offline Routing
The Problem
A service worker sits between the page and the network as a programmable proxy, and every navigation — every full-document request the browser makes when the user clicks a link or reloads — passes through its fetch event if you have registered a handler. The default posture of most handlers is optimistic: try the network, return whatever comes back. That works until the network is gone, at which point the fetch rejects with a TypeError and, if nothing catches it, the browser falls back to its own error UI.
That default failure has three distinct costs. First, brand and continuity rupture. The browser’s offline page is unstyled, unbranded, and jarring; a reader who was three screens deep in your product is suddenly staring at a system dialog with no way back except the address bar. Second, loss of control over messaging. You cannot tell the user which parts of the app still work offline, cannot offer a retry button, and cannot surface any cached content, because you have handed the moment to the browser. Third, an accessibility gap. The browser’s error page is a hard context switch that assistive technology announces as a wholly new document, with none of the landmark structure or live-region affordances your own shell provides.
The tension underneath is that navigation requests are special. Unlike a request for an image or a JSON endpoint — where a failed fetch degrades one widget — a failed navigation request replaces the entire viewport. There is no partial degradation to fall back on; either you serve a complete, valid HTML document or the browser does. That is why the fallback has to be a full, self-contained page cached ahead of time, and why the decision of which fallback to serve belongs inside the navigation branch of your fetch handler rather than in page-level JavaScript that may never get the chance to run.
This decision sits alongside your broader service worker routing strategies: the fallback is the terminal catch of whatever cache-or-network strategy you chose, and it is conceptually the client-side cousin of the server-side fallback routing strategies that catch unmatched routes. Both answer the same question — what do we show when the primary resolution path yields nothing? — at different layers of the stack.
Core API & Primitives
Three browser primitives do all the work. Understanding their exact contracts keeps the fallback logic small and correct.
The install event is your one guaranteed window to populate a cache before the service worker ever handles a request. Calling event.waitUntil() with a promise holds the worker in its installing state until precaching resolves, so the fallback document is present before any fetch event can fire.
// TypeScript 5.x — service worker global scope, no runtime dependencies
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
const OFFLINE_CACHE = "offline-fallback-v1";
const OFFLINE_URL = "/offline.html";
The fetch event exposes the intercepted Request. For a navigation you can either check event.request.mode === "navigate" or, more robustly, inspect the Request destination. The mode flag is the widely-supported discriminator and the one used throughout this guide.
// TypeScript 5.x — the shape of what a navigation handler receives
interface NavigationContext {
request: Request; // request.mode === "navigate" for document loads
isNavigation: boolean; // derived guard, computed once per fetch
preloadResponse: Promise<Response | undefined>; // navigation preload, if enabled
}
The CacheStorage API — caches.open(), cache.addAll(), cache.match() — is the persistent, origin-scoped store the fallback lives in. A Response read out of it is a full document ready to return from the fetch handler. The respondWith() method on the fetch event is where you hand that response back to the browser; whatever promise you pass it becomes the navigation’s result, so the fallback is simply the resolved value of that promise’s catch branch.
// TypeScript 5.x — the contract respondWith() expects
type RespondWith = (response: Response | Promise<Response>) => void;
// Resolve with a real Response and the browser renders it as the document.
// Reject, and the browser shows its own offline page instead.
Step-by-Step Implementation
The build proceeds in four steps: precache the fallback on install, activate cleanly, add a navigation-only fetch handler with a catch() fallback, then extend it to per-route fallbacks. Every snippet runs in the service worker’s global scope, so it assumes the self and constant declarations from the previous section. Register the worker from your page as usual before any of this takes effect; the service worker file itself is what follows.
Step 1: Precache the fallback during install
Open a versioned cache and add the offline document (plus any assets it references) inside waitUntil(). Using { cache: "reload" } on the request bypasses the HTTP cache so you never precache a stale copy. Calling skipWaiting() lets the new worker take over without waiting for every tab to close — appropriate here because the fallback is additive and cannot break live pages.
// TypeScript 5.x — service worker install handler, no runtime dependencies
self.addEventListener("install", (event: ExtendableEvent) => {
event.waitUntil(
(async () => {
const cache = await caches.open(OFFLINE_CACHE);
// Fetch fresh copies, bypassing the HTTP cache, so the precache
// never captures a stale document from a prior deployment.
await cache.addAll([
new Request(OFFLINE_URL, { cache: "reload" }),
"/offline.css",
"/offline-illustration.svg",
]);
await self.skipWaiting();
})(),
);
});
Step 2: Claim clients and prune old fallback caches
On activation, delete superseded versions of the fallback cache so an old offline.html is not left serving after a redeploy, then call clients.claim() so the active worker controls already-open tabs immediately.
// TypeScript 5.x — service worker activate handler
self.addEventListener("activate", (event: ExtendableEvent) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys
.filter((key) => key.startsWith("offline-fallback-") && key !== OFFLINE_CACHE)
.map((key) => caches.delete(key)),
);
await self.clients.claim();
})(),
);
});
Step 3: Catch failed navigations and serve the fallback
Handle only navigation requests — leave images, scripts, and API calls to their own strategies. Try the network (or the navigation preload response if you have enabled it), and in the catch branch return the precached document. Because respondWith() is given a promise that always resolves to a valid Response, the browser never reaches its own offline page.
// TypeScript 5.x — navigation-only fetch handler with an offline catch
self.addEventListener("fetch", (event: FetchEvent) => {
// Only navigations replace the whole viewport; everything else is skipped.
if (event.request.mode !== "navigate") return;
event.respondWith(
(async () => {
try {
// Prefer a preloaded response when navigation preload is on.
const preload = await event.preloadResponse;
if (preload) return preload as Response;
return await fetch(event.request);
} catch {
// Network is unreachable — serve the precached shell instead of
// letting the browser paint its generic offline page.
const cache = await caches.open(OFFLINE_CACHE);
const cached = await cache.match(OFFLINE_URL);
return (
cached ??
new Response("<h1>You are offline</h1>", {
status: 503,
headers: { "Content-Type": "text/html; charset=utf-8" },
})
);
}
})(),
);
});
Step 4: Choose a per-route fallback when one page is not enough
A single fallback is the right default. But a content-heavy app may warrant section-specific fallbacks — an offline reader for /articles/*, a different message for /account/* — so the offline copy matches the context the user was in. Map a URL prefix to a precached document and fall back to the generic shell for anything unmatched. Precache each of these documents in Step 1 alongside the default.
// TypeScript 5.x — resolve the most specific precached fallback for a path
const FALLBACKS: ReadonlyArray<readonly [prefix: string, url: string]> = [
["/articles/", "/offline-article.html"],
["/account/", "/offline-account.html"],
];
async function resolveFallback(request: Request): Promise<Response> {
const cache = await caches.open(OFFLINE_CACHE);
const { pathname } = new URL(request.url);
// Longest-prefix wins so /articles/2026/ picks the article fallback.
const match = FALLBACKS.filter(([prefix]) => pathname.startsWith(prefix)).sort(
(a, b) => b[0].length - a[0].length,
)[0];
const target = match ? match[1] : OFFLINE_URL;
return (await cache.match(target)) ?? (await cache.match(OFFLINE_URL))!;
}
Verification & Testing
Confirm the fallback both precaches and serves. The Playwright snippet below loads the app online (registering and priming the worker), forces the browser context offline, then navigates and asserts the offline document rendered — driving the real navigation path rather than synthesising a fetch, so you exercise the actual mode === "navigate" branch.
// @playwright/test v1.44 — offline navigation assertion
import { test, expect } from "@playwright/test";
test("serves the precached fallback when navigation fails offline", async ({ page, context }) => {
await page.goto("/");
// Give the service worker time to install and precache the shell.
await page.waitForFunction(() => navigator.serviceWorker.controller !== null);
await context.setOffline(true);
await page.goto("/some/uncached/route", { waitUntil: "commit" });
// The offline shell — not the browser's error page — should be visible.
await expect(page.getByRole("heading", { name: /offline/i })).toBeVisible();
await expect(page.getByRole("button", { name: /retry/i })).toBeVisible();
});
For a manual pass, open DevTools, go to the Application panel, confirm offline.html appears under Cache Storage, then switch the Network condition to Offline (or tick “Offline” in the Service Workers pane) and navigate to any route. The Network panel should show the document request served “(from ServiceWorker)”. Toggle back online and the retry affordance should recover the live page.
Performance Tuning
- Keep the fallback self-contained and small. Inline critical CSS and the illustration as a
data:URI or a small precached asset so the offline document renders without any further cache lookups. A fallback that references ten uncached files is a fallback that renders broken. - Enable navigation preload. Calling
self.registration.navigationPreload.enable()in the activate handler lets the browser start the network request in parallel with worker boot, shaving latency off every successful online navigation while leaving the offlinecatchpath untouched. - Version the cache, prune on activate. A monotonic cache name (
offline-fallback-v2) plus the activate-time cleanup in Step 2 keeps CacheStorage from accumulating dead fallback copies across deploys. - Do not precache the whole app as fallback. The offline page is a lightweight safety net, not an offline mirror. Full offline content belongs to a deliberate caching strategy and app-shell precache, kept separate so the fallback stays cheap to install.
- Match with
ignoreSearchwhen appropriate. If your fallback is keyed without query strings, pass{ ignoreSearch: true }tocache.matchso/offline.html?ref=xstill resolves rather than missing and dropping to the browser page.
Gotchas & Failure Modes
- Handling non-navigation requests in the same branch. If you forget the
mode === "navigate"guard, the fallback logic runs for images and API calls too, returning an HTML document where JSON was expected. Guard first, return early. - Precaching a stale document. Without
{ cache: "reload" },addAllmay pulloffline.htmlfrom the HTTP cache and pin last week’s copy. Bypass the HTTP cache on install. - The fallback references uncached assets. A precached document that links to a stylesheet or font you did not also precache renders unstyled offline — the exact rupture you were trying to avoid. Precache every asset the fallback needs, or inline them.
skipWaitingwithout cleanup. Taking over immediately while leaving old caches around means a redeploy can serve a mix of old and new fallbacks. PairskipWaiting()with the activate-time prune.- No offline announcement for assistive technology. Rendering an offline page silently means a screen-reader user may not learn the navigation failed. Give the fallback a focusable heading and an
aria-liveregion, and move focus to it on load. - Retry that reloads into the same failure. A retry button that simply calls
location.reload()while still offline loops back to the fallback. Gate retry onnavigator.onLineand listen for theonlineevent to enable it.
Go Deeper
- Building a Custom Offline Fallback Page — a focused walkthrough of precaching a single
/offline.htmlshell on install and serving it from the navigation catch handler, verified with the DevTools offline throttle.
Related
- Service Worker & Offline Routing — the parent overview of intercepting navigation with a service worker and choosing a caching posture.
- Service Worker Routing Strategies — cache-first, network-first, and stale-while-revalidate patterns whose terminal catch is the offline fallback.
- App Shell Caching — precaching the reusable shell that renders instantly offline, distinct from the fallback safety net.
- Fallback Routing Strategies — the routing-layer cousin that catches unmatched routes rather than failed networks.