Building a Custom Offline Fallback Page
After reading this you will be able to precache a single, self-contained /offline.html shell when your service worker installs, and return it from the navigation catch handler so a reader who goes offline sees your page instead of the browser’s generic error screen.
← Back to Offline Fallback Pages
Prerequisites
Core Concept
A navigation request — the full-document fetch the browser makes when a user clicks a link or reloads — replaces the whole viewport, so there is no partial degradation when it fails: either you return a complete HTML document or the browser paints its own offline page. The fix is to cache one deliberately-designed document, /offline.html, before the worker ever handles a request, then hand that document back whenever a navigation fetch rejects. The install event is the only lifecycle phase guaranteed to run before any fetch, which makes it the correct place to precache; the catch branch of the navigation handler is the only place the fallback should ever be returned, because that is precisely the moment the network has failed. Keeping the shell self-contained — inlined CSS, no external font or script it cannot reach offline — is what separates a clean fallback from one that renders as unstyled text.
Implementation
The whole feature is one install handler and one fetch handler. The install handler precaches /offline.html with the HTTP cache bypassed; the fetch handler intercepts navigations only, tries the network, and serves the cached shell on failure.
// TypeScript 5.x — service worker global scope, no runtime dependencies
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
const CACHE = "offline-shell-v1";
const OFFLINE_URL = "/offline.html";
// 1. Precache the shell during install, before any fetch can fire.
self.addEventListener("install", (event: ExtendableEvent) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE);
// cache: "reload" skips the HTTP cache so we never pin a stale copy.
await cache.add(new Request(OFFLINE_URL, { cache: "reload" }));
await self.skipWaiting();
})(),
);
});
// Take control of open tabs as soon as the new worker activates.
self.addEventListener("activate", (event: ExtendableEvent) => {
event.waitUntil(self.clients.claim());
});
// 2. Serve the shell whenever a navigation fetch fails.
self.addEventListener("fetch", (event: FetchEvent) => {
// Only whole-document navigations qualify; skip assets and API calls.
if (event.request.mode !== "navigate") return;
event.respondWith(
(async () => {
try {
// Optimistic: reach for the live page while we still have a network.
return await fetch(event.request);
} catch {
// Offline — return the precached shell, guaranteed present since install.
const cache = await caches.open(CACHE);
const cached = await cache.match(OFFLINE_URL);
return (
cached ??
new Response("Offline", {
status: 503,
headers: { "Content-Type": "text/html; charset=utf-8" },
})
);
}
})(),
);
});
The mode !== "navigate" guard is the load-bearing line: without it the shell would also be returned for failed image and data requests, handing HTML to code expecting bytes or JSON. The cache.match fallback to an inline 503 response means the handler resolves with a valid Response even in the pathological case where the precache was somehow evicted, so the browser’s own error page is never reached.
Verification
Load the app once online so the worker installs, then open DevTools → Application → Cache Storage and confirm offline-shell-v1 contains /offline.html. In the Service Workers pane (or the Network panel’s throttling dropdown) tick Offline, then navigate to any route. The document should render your shell, and the Network entry should read “(from ServiceWorker)”.
// @playwright/test v1.44 — assert the shell renders when offline
import { test, expect } from "@playwright/test";
test("navigation offline renders the custom shell", async ({ page, context }) => {
await page.goto("/");
await page.waitForFunction(() => navigator.serviceWorker.controller !== null);
await context.setOffline(true);
await page.goto("/anything", { waitUntil: "commit" });
await expect(page.getByRole("heading", { name: /you.?re offline/i })).toBeVisible();
});
Gotchas
- The shell must inline its own CSS and any icon; if it links to an uncached stylesheet, it renders unstyled offline — the exact failure you set out to avoid.
- Without
{ cache: "reload" }on install,cache.addmay capture a stale/offline.htmlfrom the HTTP cache and keep serving last deploy’s copy until you bump the cache name. - Give the shell a focusable
<h1>and anaria-live="assertive"status region, and move focus to the heading on load, so screen-reader users are told the navigation failed. - A retry button that calls
location.reload()while still offline just loops back to the shell; gate it onnavigator.onLineand enable it from anonlineevent listener.
FAQ
Why cache the fallback on install rather than the first time it is needed? Because the first time it is needed is the moment the network is gone — if you have not already stored the document, there is nothing to serve and the browser wins. The install event runs while you are still online, which is the only reliable window to precache.
Do I need to intercept every request to serve an offline page? No. Only navigation requests replace the viewport, so guarding on event.request.mode === "navigate" and returning early otherwise is both sufficient and safer, since it leaves image, script, and API requests to their own handling.
What status code should the offline shell return? When it comes from the cache after a real network failure it carries whatever status was cached, typically 200. For the synthetic last-resort response, 503 Service Unavailable is the honest choice because the content genuinely could not be retrieved.
Will the offline page get stale when I redeploy? It will if you reuse the cache name. Bump the version (offline-shell-v2) and delete old caches in the activate handler so a redeploy replaces the shell rather than layering a stale copy underneath.
Can I show different offline pages for different sections? Yes, but that is the per-route pattern covered in the parent Offline Fallback Pages guide; a single custom shell is the right default and everything above is enough to ship it.
Related
- Offline Fallback Pages — the parent guide covering per-route fallbacks, accessibility, and the full fetch-handler design.
- Service Worker Routing Strategies — the cache-or-network strategies whose failure path this shell terminates.
- Fallback Routing Strategies — the routing-layer equivalent that catches unmatched routes rather than a dead network.