This page is part of PWA Offline Recovery & Service-Worker Resilience, which covers the full lifecycle of registering, updating, and recovering a service worker so a web app keeps working when the network doesn’t.

The problem: navigations that fall through to the browser’s offline page #

A user closes their laptop mid-session, reopens it on a train with no signal, and taps a bookmark or a push notification deep-link into your app. If that route was never visited before — never rendered into memory, never fetched by this browser — there is nothing in any cache for it, and no service worker fetch handler intervenes to intercept the request. The navigation falls straight through to the browser’s own network-error page: the dinosaur on Chrome, the Wi-Fi-cable illustration on Safari, a plain “can’t reach this page” on Firefox. Whatever branding, retry affordance, or cached-content shortcuts your app could have offered are simply absent, because the request never touched your code.

This is distinct from an asset failing to load. A missing stylesheet or script degrades the page but the document itself still renders. A failed navigation — the top-level HTML document request — replaces the entire tab with the browser’s chrome, and there is no way to recover from inside that page because your JavaScript never executed. The only fix is upstream: a service worker fetch handler that intercepts navigation requests before the browser’s default network stack gets a chance to fail them visibly.

A second, quieter version of the same problem shows up on first load: if the app shell’s first paint depends on a network round trip for the HTML, CSS, and JS bundle, a slow or flaky connection delays everything, even for a returning visitor whose assets haven’t changed since their last visit. And a third failure mode surfaces after every deploy — if your caching strategy pins hashed asset URLs indefinitely, a stale service worker keeps serving app.a1b2c3.js after the build renamed it to app.d4e5f6.js, and the old bundle either 404s against a CDN that’s already evicted it or, worse, keeps running against APIs that have moved on.

User-visible consequence: an unbranded browser error screen on any uncached route while offline, a slow or blank first paint on marginal connections, and intermittent 404s for renamed assets immediately after a release.

Prerequisites #


Core implementation: install, activate, and fetch handlers #

The worker below precaches the app shell and the /offline/ fallback on install, purges every previous cache version on activate, and then routes fetch events with two strategies: network-first for navigations (with an offline fallback) and cache-first for anything already in the precache manifest.

/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;

const CACHE_VERSION = 'v3';                 // bump on every deploy that changes assets
const CACHE_V = `app-shell-${CACHE_VERSION}`;
const OFFLINE_URL = '/offline/';

// Build-generated manifest: hashed asset URLs + the app shell document +
// the offline fallback. In production this array is emitted by the same
// bundler step that produces the content hashes, so it never drifts.
const PRECACHE_URLS: readonly string[] = [
  '/',
  OFFLINE_URL,
  '/assets/css/main.a1b2c3.css',
  '/assets/js/app.d4e5f6.js',
  '/assets/img/logo.svg',
];

self.addEventListener('install', (event: ExtendableEvent) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(CACHE_V);
      // addAll() is atomic: if any single URL 404s, nothing is written and
      // install rejects, leaving the previous worker in control instead of
      // shipping a half-populated shell.
      await cache.addAll(PRECACHE_URLS as string[]);
      // Activate this worker immediately rather than waiting for every
      // open tab of the old worker to close.
      await self.skipWaiting();
    })()
  );
});

self.addEventListener('activate', (event: ExtendableEvent) => {
  event.waitUntil(
    (async () => {
      const keys = await caches.keys();
      // The atomic swap: delete every cache key that isn't this version,
      // only after the new cache has already been fully populated above.
      await Promise.all(
        keys.filter((key) => key !== CACHE_V).map((key) => caches.delete(key))
      );
      await self.clients.claim();
    })()
  );
});

self.addEventListener('fetch', (event: FetchEvent) => {
  const { request } = event;

  // Navigations: network-first, falling back to the cached offline shell.
  if (request.mode === 'navigate') {
    event.respondWith(
      (async () => {
        try {
          return await fetch(request); // default redirect: 'follow' — leave it alone
        } catch {
          const cache = await caches.open(CACHE_V);
          const offline = await cache.match(OFFLINE_URL);
          return offline ?? Response.error();
        }
      })()
    );
    return;
  }

  // Precached static assets: cache-first, no network round trip at all.
  const path = new URL(request.url).pathname;
  if (PRECACHE_URLS.includes(path)) {
    event.respondWith(
      (async () => {
        const cache = await caches.open(CACHE_V);
        const cached = await cache.match(request);
        return cached ?? fetch(request);
      })()
    );
  }
  // Anything else falls through to runtime caching (see Advanced variant).
});

Architecture note: the app-shell model, versioning, and navigation detection #

This worker implements the classic app-shell model: the minimal HTML/CSS/JS required to render the page frame is precached once and reused across every navigation, while the content within that frame is fetched or hydrated separately. request.mode === 'navigate' is the load-bearing check — it’s true only for top-level document requests triggered by address-bar entry, link clicks, form submissions, and window.location changes, and false for fetch() calls, <img> loads, or subresource requests. Routing on mode rather than on file extension or Accept header means the same handler correctly intercepts every client-side route an SPA router can produce, without needing a route table inside the worker.

Cache versioning plus the delete-on-activate pass is what makes deploys safe. Because install only calls skipWaiting() after cache.addAll() resolves, the new cache is guaranteed complete before the old one is touched — there’s no window where a client could see a mix of two versions’ assets. clients.claim() inside activate then hands control of already-open tabs to the new worker immediately, rather than waiting for a full reload, which is what makes the next navigation’s cache-first lookups resolve against the new manifest right away.


Edge cases and gotchas #

Fetch-routing decision tree A flowchart: a fetch event checks request.mode. Navigate requests use network-first, succeeding with the network response or falling back to the cached offline page on failure. Non-navigate requests use cache-first, serving a cache hit directly or falling through to a stale-while-revalidate fetch-and-cache on a miss. fetch event fires event.request request.mode === 'navigate' ? yes Network-first fetch(request) redirect: 'follow' (default) no Cache-first caches.match(request) precached static asset Success respond(networkResponse) fresh document served Failure caches.match('/offline/') branded fallback shown Hit respond(cachedAsset) zero network calls Miss fetch + cache.put stale-while-revalidate

Beyond the branch logic above, four gotchas account for most of the bugs in production precache-and-routing implementations:

Failure mode Symptom Mitigation
Opaque cross-origin responses cache.put() silently stores a response with status: 0 from a no-cors third-party request (fonts, analytics) that can never be inspected for success Only cache same-origin (response.type === 'basic') or explicitly CORS-enabled responses; treat opaque responses as non-cacheable
Cache Storage quota eviction The browser silently evicts an entire origin’s caches under storage pressure, and a previously precached route suddenly falls through to the network Keep the precache manifest small and versioned; call navigator.storage.persist() for installed PWAs; never assume a cache entry that existed yesterday still exists today
Range requests for media cache.match() on a <video>/<audio> byte-range request (Range: bytes=…) doesn’t return partial content, so seeking breaks or the whole file re-downloads Exclude media URLs from cache-first matching, or check request.headers.has('range') and pass those requests straight to fetch() uncached
Redirect-mode pitfall on navigations Setting redirect: 'manual' on a navigation fetch() (common when copy-pasting subresource caching code) turns every redirect into an opaque-redirect response the browser refuses to use for a navigation, throwing TypeError: inappropriately configured to follow redirects Leave redirect at its default 'follow' for anything passed to respondWith() during a navigate event

Advanced variant: stale-while-revalidate and manifest versioning #

Precached assets are cache-first because they’re immutable — a hashed filename never changes its content. Everything else (API responses cached opportunistically, non-hashed images, third-party widgets) benefits from stale-while-revalidate: serve the cached copy instantly, then refresh it in the background for the next request. The handler below also demonstrates the defensive pattern that keeps a fetch handler from ever throwing — every branch resolves to a Response, even in the worst case.

const RUNTIME_CACHE = `runtime-${CACHE_VERSION}`;

async function staleWhileRevalidate(request: Request): Promise<Response> {
  const cache = await caches.open(RUNTIME_CACHE);
  const cached = await cache.match(request);

  // Always kick off revalidation, whether or not there's a cached hit —
  // this is what keeps the NEXT visit fresh without blocking THIS one.
  const revalidate = fetch(request)
    .then((response) => {
      // Never cache opaque or non-OK responses: opaque responses report
      // status 0 and would silently cache an error as if it were content.
      if (response.ok && response.type === 'basic') {
        void cache.put(request, response.clone());
      }
      return response;
    })
    .catch(() => undefined);

  return cached ?? (await revalidate) ?? Response.error();
}

self.addEventListener('fetch', (event: FetchEvent) => {
  const { request } = event;
  if (request.mode === 'navigate' || request.method !== 'GET') return;

  const path = new URL(request.url).pathname;
  if (PRECACHE_URLS.includes(path)) return; // already handled cache-first above
  if (request.headers.has('range')) return; // let media byte-range requests hit the network directly

  // A guarded wrapper: if staleWhileRevalidate itself throws synchronously
  // (malformed URL, extension-injected request), still resolve the event
  // rather than letting it reject unhandled.
  event.respondWith(
    staleWhileRevalidate(request).catch(() => fetch(request).catch(() => Response.error()))
  );
});

Manifest versioning ties directly into this: RUNTIME_CACHE derives from the same CACHE_VERSION constant as the precache, so a single bump invalidates both in one deploy. Treat that constant as generated output, not hand-edited state — wire it to your build’s content hash or git SHA so a forgotten manual bump can never leave a worker serving a stale manifest against a new deploy.


Testing and CI/CD validation #

Precache-and-routing regressions are invisible in a normal online test run — everything works when the network is available. Validation has to explicitly force the offline path.

import { test, expect } from '@playwright/test';

test('serves the offline fallback when an uncached navigation fails', async ({ page, context }) => {
  await page.goto('/'); // registers the worker and warms the precache on install
  await page.waitForFunction(() => navigator.serviceWorker.controller !== null);

  await context.setOffline(true);
  await page.goto('/some/uncached/route/', { waitUntil: 'domcontentloaded' }).catch(() => {});

  await expect(page.locator('h1')).toHaveText(/you.re offline/i);
});

Run Lighthouse’s PWA category in CI and assert on the works-offline audit specifically — it drives a real offline navigation against the built site and fails the run if any route falls through to the browser’s own error page. Pair that with a manual DevTools check during development: Application panel → Service Workers → “Offline” checkbox, then navigate to a route you haven’t visited yet in that session. If the branded /offline/ page doesn’t render, the install handler either isn’t precaching it or the fetch handler isn’t matching request.mode === 'navigate' correctly — check both before assuming the cache itself is empty.

For the offline page’s own content and layout — retry affordance, connectivity polling, cached-content shortcuts — see Serving an Offline Fallback Page from the Service Worker, which builds directly on the caches.match(OFFLINE_URL) call shown above.


Frequently asked questions #

Why does the browser show its own offline dinosaur page instead of my branded fallback? Because no fetch handler intercepted the navigation. If the service worker has no fetch listener at all, or the listener never calls respondWith() for that particular request, the browser’s own network stack handles the failure and renders its default error page — your app’s JavaScript never runs, so there’s no way to recover from inside it.

Do I need Workbox, or can I write the precache and routing logic by hand? Hand-written install/activate/fetch handlers, as shown above, are entirely production-viable for a fixed app shell. Workbox mainly saves effort at scale by auto-generating the precache manifest from your build output and providing prebuilt route strategies; for a small, well-understood shell, three explicit event listeners are easier to debug when something goes wrong.

How do I stop stale hashed asset URLs from 404ing after a deploy? Bump CACHE_VERSION every time the precache manifest changes, and generate that manifest from the same build step that emits the content hashes so the two never drift apart. The activate handler’s cache-deletion pass then guarantees that a client reopening the app after a deploy fetches the current manifest’s filenames rather than a stale mix of old and new assets.

Why must the fetch handler never throw? A synchronous throw inside a fetch listener, or an unhandled rejection inside the promise passed to respondWith(), aborts the request with a generic network error — functionally identical to the dino page this whole pattern exists to avoid. Wrapping every branch in try/catch and always resolving to a Response (even a manufactured Response.error()) guarantees a deterministic result instead of an aborted load.

How does this interact with the service worker’s own lifecycle events? Precaching happens entirely inside install and cache cleanup inside activate — see Service Worker Lifecycle & Registration Recovery for how skipWaiting() and clients.claim() interact with tabs that are already open when a new worker is deployed, including the stuck-worker scenarios that can leave clients pinned to a version whose caches have already been deleted.