This page narrows one piece of App Shell Precache & Offline Fallback Routing: serving a dedicated fallback screen the instant a navigation fails, rather than the app shell’s broader precache strategy.
The exact failure this page solves #
A user loses connectivity on a train, then taps a link to a route their service worker never precached — a deep product page, a rarely visited settings screen, anything outside the app shell. The browser’s own fetch fails, and because nothing intercepts it, Chrome or Safari renders their stock “no internet” interstitial: a dinosaur, a Wi-Fi icon, or a blank error page with no branding, no navigation, and no way back into the app short of hitting the browser’s back button. For a PWA that otherwise works offline, this is a jarring dead end that makes the rest of the offline investment look broken.
The fix is narrow and specific: precache one /offline/ route (plus its own CSS and any inline image) during the service worker’s install event, then in fetch detect navigation requests specifically — event.request.mode === 'navigate' — try the network first, and on failure resolve with caches.match('/offline/'). Every other request type (scripts, styles, API calls, images) keeps whatever strategy the rest of the worker already uses; this pattern touches only the navigation branch.
Network-first, rather than cache-first, is the correct order for navigations specifically. A cache-first navigation strategy would serve a stale precached document even when the network is healthy, which is wrong for any route outside the app shell — those pages were never meant to be served from cache at all. Trying the network first and falling back only on rejection means the fallback page is invisible to any user with a working connection; it exists purely as a safety net for the moment the network genuinely fails.
Zero-to-working implementation #
The snippet below is a complete, minimal service worker focused on the offline-fallback behavior. It assumes the app shell’s other caching logic lives alongside it — the fetch handler shown here only adds the navigation branch and leaves a placeholder for existing asset strategies.
// sw.js
const CACHE_VERSION = 'offline-fallback-v3';
const OFFLINE_ASSETS = [
'/offline/',
'/offline/index.html',
'/assets/css/offline.css',
'/assets/img/offline-icon.svg',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_VERSION).then((cache) => cache.addAll(OFFLINE_ASSETS)),
);
self.skipWaiting(); // activate this version without waiting for old tabs to close
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_VERSION)
.map((key) => caches.delete(key)),
),
),
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const { request } = event;
// Only intercept top-level navigations; leave every other request type untouched.
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() =>
caches.match('/offline/').then((cached) => cached ?? Response.error()),
),
);
return;
}
// Existing asset strategies (cache-first, stale-while-revalidate, etc.)
// continue to run here for scripts, styles, images, and API calls.
event.respondWith(caches.match(request).then((cached) => cached ?? fetch(request)));
});
This is deliberately small: it does not attempt to precache the whole app shell, does not implement stale-while-revalidate for the offline route, and does not touch background sync. It solves exactly one failure — an uncached navigation while offline — and nothing else.
Step-by-step explanation #
-
Version the cache name.
CACHE_VERSIONis a plain string with a version suffix. Every deploy that changes/offline/or its assets must bump this string soactivatetreats the previous cache as stale. -
List the offline assets explicitly.
OFFLINE_ASSETSincludes the HTML route itself and every resource it needs to render standalone — its stylesheet and any inline image. If the offline page references an uncached font or script, it will render broken while offline, defeating the purpose. -
Precache in
install.cache.addAll(OFFLINE_ASSETS)fails atomically if any one URL 404s, so a typo in the asset list surfaces immediately during development rather than silently at runtime.self.skipWaiting()lets the new worker activate without waiting for every open tab to close, which matters if you are iterating on this page — see recovering from a stuck service worker if updates seem to get stuck behind an old worker instance. -
Check
request.mode === 'navigate'first. This condition is the entire mechanism. It istrueonly for top-level document navigations — typing a URL, clicking a link, following a bookmark — andfalsefor every sub-resource fetch a page makes, so the branch never intercepts an image or API call by accident. -
Try the network, then fall back.
fetch(request)inside the navigate branch means a user with connectivity always gets the live, current page — the fallback only ever engages on rejection, whichfetch()throws when the network is unreachable or times out. -
Resolve with the precached fallback.
caches.match('/offline/')looks up the exact key stored duringinstall, independent of whatever URL the user actually tried to reach. Thecached ?? Response.error()guard avoids returningundefinedtorespondWithif the precache step was somehow skipped. -
Clean up old cache versions in
activate. Deleting any cache key that isn’tCACHE_VERSIONguarantees a bumped version fully replaces the previous offline page rather than leaving two versions coexisting in storage.
Fallback routing flow #
The diagram below traces a single navigation from the moment the fetch listener sees it to whichever response the browser actually paints — either the live network document or the precached fallback.
Edge cases #
| Scenario | Symptom | Mitigation |
|---|---|---|
| Non-navigation request offline (image, API call) | Falls through to the generic caches.match(request) branch, not the offline page |
Correct — only request.mode === 'navigate' should ever resolve to /offline/; do not widen the condition to request.destination === 'document' alone, since that also matches iframes you may want to fail silently |
Offline page’s own CSS/image missing from OFFLINE_ASSETS |
Fallback renders unstyled or with a broken image while offline | Audit every <link>/<img> the offline template emits and add each URL to OFFLINE_ASSETS; a build-time script that greps the offline template is more reliable than a hand-maintained list |
Cache version bumped but activate never runs |
Users keep seeing the old offline page indefinitely | Confirm activate’s caches.keys() cleanup runs and that clients.claim() is present so the new worker controls open tabs immediately, not just new ones |
HEAD or Range request for a navigation-adjacent resource |
mode is 'no-cors' or 'same-origin', not 'navigate'; falls through unexpectedly |
Confirm the check is request.mode === 'navigate' and not request.destination === 'document', since range requests for prefetched documents can share a destination but never share the navigate mode |
request.mode is deliberately the narrower check. request.destination === 'document' also matches <iframe> and <embed> loads, prerender hints, and certain prefetch requests a browser issues speculatively — none of which are a real top-level navigation the user is looking at. Routing those through /offline/ would silently swap embedded content for the fallback page, which is rarely what the surrounding app intends. request.mode === 'navigate' is set only for the actual document load a user initiated, which is exactly the boundary this pattern needs.
Cache-version invalidation deserves its own note: bumping CACHE_VERSION does nothing by itself for tabs that are already open and controlled by the old worker. The new worker installs, precaches the new /offline/ assets under the new cache key, and waits in the “waiting” state until every existing tab navigates away or closes — unless skipWaiting() runs, in which case activate fires immediately and clients.claim() hands control of open tabs to the new worker. Skipping either call means some visitors keep seeing the previous fallback page for an indefinite amount of time even though the new version is technically installed.
Verification steps #
-
Precache confirmation. Open DevTools → Application → Cache Storage, expand the
offline-fallback-v3entry, and confirm/offline/,/offline/index.html, and its CSS/image all appear immediately after the worker’sinstallevent completes. -
Simulate offline navigation. In DevTools → Network, set throttling to “Offline.” Navigate to a URL your service worker never precached — a route with no matching cache entry. Confirm the branded
/offline/page renders instead of the browser’s stock no-connection screen. -
Confirm the network-first path still wins online. With throttling back to “No throttling,” reload the same uncached route. Confirm the live page loads normally — the fallback should never engage while the network succeeds.
-
Confirm sub-resources are unaffected. While offline, check the Network panel for an uncached image or API request on a page that is already loaded. It should fail with its own network error, not silently redirect to
/offline/. -
Version bump check. Change
CACHE_VERSION, redeploy, and after the new worker activates, confirm in Application → Cache Storage that only the new version’s cache key remains — the old one should be gone. -
Lighthouse PWA audit. Run Lighthouse’s Progressive Web App category (or the standalone
offlinecustom audit in DevTools). A correctly wired fallback shows a passing “responds with a 200 when offline” check for a navigation to an uncached route, rather than the failing state Lighthouse reports when no fallback exists.
Frequently asked questions #
Why does the offline page still show the browser’s default error instead of my fallback?
The most common cause is that the fetch handler never checks request.mode, so the navigate branch is unreachable and the request falls through to a generic cache-first strategy with no matching entry. Confirm the navigation check runs before any other conditional in the handler, and confirm caches.match('/offline/') is called with the exact precached URL rather than request.url, which will not match if the user tried a different path.
Do I need to handle offline fallbacks for images and API calls too?
Not with this pattern. This page replaces only failed document navigations; sub-resource requests such as images, scripts, and fetch() calls to an API should keep their own cache-first or network-first strategy from the rest of the worker and simply fail or return a cached asset as before, rather than being redirected to /offline/.
Will updating the offline page’s HTML automatically reach existing users?
Only after the cache version changes. Bump the CACHE_VERSION suffix, redeploy, and let the activate handler delete the old cache; otherwise returning visitors keep the previously precached offline page until their service worker updates, since install only re-runs cache.addAll when the worker script itself changes.
Related #
- App Shell Precache & Offline Fallback Routing — parent guide covering the broader precache strategy this fallback page is one piece of
- Recovering from a Stuck Service Worker with skipWaiting and clients.claim — what to check when an updated offline page never reaches active tabs
- PWA Offline Recovery & Service-Worker Resilience — the broader collection of offline-durability and service-worker recovery patterns