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.

Five tests for an offline fallback page Five tests of increasing realism: the DevTools offline toggle, a genuinely disabled adapter, a cold visit with the cache cleared, loading with stylesheets blocked, and a stalled rather than absent connection. Testing an offline page honestly test catches DevTools offline checkbox the basic path nothing about assets disable the network adapter a real disconnection differences in error timing clear the cache, then go offline a cold offline visit a fallback that was never precached load it with CSS blocked external dependencies an unstyled fallback throttle to a stalled connection the lie-fi case a page that hangs instead of falling back
The DevTools checkbox passes almost any implementation; the last three are the ones that find real problems.

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();
});
What an offline page may and may not reference Five asset dependencies and their offline outcome: external stylesheets, fonts, image files and scripts all fail, while inline CSS and inline SVG render correctly because they require no request. Assets an offline page must not depend on dependency offline result an external stylesheet a separate request unstyled page a web font a separate request fallback font, possible layout shift an SVG logo file a separate request a broken image icon an analytics script a separate request fails, and may block inline CSS and inline SVG no request at all renders exactly as designed
The last row is the whole design constraint: an offline page should be one file that needs nothing else.

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.add may capture a stale /offline.html from the HTTP cache and keep serving last deploy’s copy until you bump the cache name.
  • Give the shell a focusable <h1> and an aria-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 on navigator.onLine and enable it from an online event listener.

There is a design question underneath the implementation that is worth answering explicitly: what should the offline page assume the reader already knows? The safe assumption is very little. Many users do not distinguish between “this site is down” and “my connection dropped”, and the two call for entirely different actions — waiting versus checking a router, moving somewhere with signal, or simply trying again in a moment.

Saying plainly that the device appears to be offline, rather than showing a generic error, is therefore the single most useful sentence on the page. Following it with a retry control matters almost as much, because by the time someone has read the message the connection has often already returned, and asking them to find the reload button themselves is a small unnecessary friction at a moment they are already frustrated.

The third element — a short list of pages that remain available from the cache — is the one that changes the page from an apology into something usable. It is also the element most implementations omit, usually because it requires reading the cache rather than shipping a static file. Doing it well means enumerating the runtime cache at render time and linking whatever is there, which is perhaps fifteen lines and turns a dead end into a choice.

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.

What belongs on an offline page Five candidate elements for an offline page: a plain cause statement, a retry action, a list of still-available cached pages, reassurance about in-progress work, and a technical code which does not belong. What to tell the user on the offline page element why it helps a plain statement of the cause "You are offline" distinguishes it from a site error a retry button one tap to try again the connection is often already back a list of cached pages links that will work turns a dead end into a choice what was in progress "your draft is saved" removes the fear of data loss a technical error code noise for most readers leave it to the console
The third row is the one most offline pages omit, and the one that turns a dead end into a usable screen.