Skip to content

Concept in Service Worker

Offline fallback for service workers

Published

An offline fallback is a response the service worker stores during install and returns from the fetch handler when the network request fails and Cache Storage has nothing for that URL, so the user sees an application page instead of the browser’s error screen. It uses only FetchEvent, cache.add(), and caches.match(), which are Baseline widely available (Chrome 40, Firefox 44, Safari 11.1, Edge 17; BCD api.FetchEvent), so the pattern works identically wherever a service worker runs.

A fallback is the last branch of a Network First or Cache First handler, not a strategy of its own. The order of checks determines what the user sees:

  1. fetch(event.request) rejects. It rejects only on network failure (DNS, TCP, TLS, or an aborted connection); a 404 or 503 resolves, so a server error is not an offline condition and should be returned as-is.
  2. caches.match(event.request) returns undefined, meaning this exact URL was not cached, or has since been evicted.
  3. The handler returns the precached fallback for the request’s type: /offline.html for event.request.mode === 'navigate', a placeholder SVG for event.request.destination === 'image', a synthetic JSON body for /api/ paths.
  4. For anything else the handler returns Response.error(), a network-error response with type 'error' and status 0, which makes the page’s fetch() reject exactly as it would without a worker.

The mode === 'navigate' guard matters because the browser’s own offline page is only a problem for navigations; returning HTML for a failed script or image request would be parsed as the wrong type. The fallback must be cached in install with cache.add() (or inside addAll()), so the install fails when the fallback URL is unavailable; a fallback fetched lazily would be missing exactly when it is needed. An <a href> on the fallback page to any URL not in the cache will fail in the same way, so the page should link only to precached routes or carry its own retry button that calls location.reload().

Fallback HTML is served with the status the cached response carried (200 for a precached file) unless the handler constructs a new Response with status: 503; the browser renders either, so the choice mainly affects server-side analytics that read status codes. The fallback page itself should not reference uncached scripts, stylesheets, or web fonts: each one fails with the same network error and the page renders unstyled.

The first example covers navigations and images with explicit fallbacks and Response.error() for everything else; the second shows the same in Workbox and how the page detects whether a fallback is in place.

Precaching and serving a page and an image fallback

Section titled “Precaching and serving a page and an image fallback”

Precache both assets in install so the worker refuses to install without them. The fetch handler only intervenes for the two request types that have a fallback; other requests fall through to the network untouched.

const FALLBACK_CACHE = 'fallback-v1';
const OFFLINE_PAGE = '/offline.html';
const OFFLINE_IMAGE = '/offline.svg';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(FALLBACK_CACHE).then((cache) => cache.addAll([OFFLINE_PAGE, OFFLINE_IMAGE]))
);
});
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.mode !== 'navigate' && request.destination !== 'image') return;
event.respondWith((async () => {
try {
return await fetch(request);
} catch {
const cached = await caches.match(request);
if (cached) return cached;
const fallback = await caches.match(request.mode === 'navigate' ? OFFLINE_PAGE : OFFLINE_IMAGE);
return fallback ?? Response.error();
}
})());
});

Returning the SVG for a failed <img> costs nothing visible beyond the placeholder; returning /offline.html for a navigation replaces the entire page, so the handler checks caches.match(request) first and only falls back when the exact URL is absent. Response.error() at the end preserves the browser’s native behaviour for a request type the worker did not plan for.

Workbox setCatchHandler and page-side detection

Section titled “Workbox setCatchHandler and page-side detection”

setCatchHandler runs when every registered route’s handler throws, which is what a failed NetworkFirst does. matchPrecache() reads from the precache created by precacheAndRoute(), so /offline.html needs to be in the build manifest. On the page, a HEAD request to the fallback URL (served from cache when the worker is active) tells whether the fallback is installed; without a worker the page shows a plain “connection lost” message driven by the offline event instead.

sw.js
import { precacheAndRoute, matchPrecache } from 'workbox-precaching';
import { setCatchHandler } from 'workbox-routing';
precacheAndRoute(self.__WB_MANIFEST); // the manifest must include /offline.html
setCatchHandler(async ({ request }) => {
if (request.destination === 'document') return matchPrecache('/offline.html');
if (request.destination === 'image') return matchPrecache('/offline.svg');
return Response.error();
});
// page.js
if (navigator.serviceWorker?.controller) {
// A controlled page: navigations will get /offline.html on failure.
} else {
window.addEventListener('offline', () => showBanner('Connection lost. Changes are saved when you are back online.'));
}

Workbox adds the response-cloning, status filtering, and cache naming that the hand-written version omits, at the cost of a build step to produce __WB_MANIFEST; the destination === 'document' test is equivalent to the mode === 'navigate' guard above for top-level navigations and also matches iframes.

Specifications

SpecificationStatus
None.