Service Worker & Offline Routing
A service worker is a routing layer the browser installs on your behalf: once registered, it sits between every navigation request and the network, and its fetch handler decides — per request — whether the response comes from the cache, the network, or a hand-authored offline page. This guide establishes the mental model, the browser primitives, and the architectural tradeoffs that let you serve a fast, reliable, offline-capable application without breaking crawlability or the back/forward cache. It is the companion to client-side routing architecture: where a client router maps URLs onto components inside one document, a service worker maps requests onto responses beneath the document entirely.
The Mental Model
The most useful way to think about a service worker is as a programmable proxy that runs in your own origin. It is not part of any document; it is a separate, event-driven worker thread with no DOM access, a lifecycle independent of the pages it controls, and the ability to outlive every tab. When a controlled page issues a request — for the HTML of a navigation, a script, an image, an API call — the browser dispatches a fetch event to the worker before touching the network. Whatever the worker passes to event.respondWith() becomes the response. If the worker declines to handle the event, the request falls through to the network as if no worker existed. That single interception point is the entire routing surface.
Three sub-problems fall out of this idea, and they are the topics this guide covers in depth.
First, strategy: given an intercepted request, where should the response come from and in what order? The choices — cache-first, network-first, and stale-while-revalidate — are the routing decisions of the offline world, and choosing correctly per request type is the substance of service worker routing strategies. A hashed, immutable asset wants cache-first; a navigation to fresh content usually wants network-first with a cache fallback.
Second, resilience: what does the user see when the network is genuinely unreachable and nothing usable is cached? A well-built application ships a hand-authored offline fallback page so a failed navigation lands on a branded, useful screen rather than the browser’s dinosaur. This is the offline-first counterpart to the client-side fallback routing strategies that catch unmatched routes inside a running application.
Third, the app shell: a single-page application separates its unchanging chrome — the HTML skeleton, the framework runtime, the top-level CSS and JavaScript — from the content that fills it. Precaching that shell so it loads instantly on every visit, online or off, is app shell caching, and it is what makes an installed web app feel native. The shell is served from the Cache Storage API while the client router, powered by the History API, swaps content into it without a document reload.
The pivotal detail that makes all three tractable is request.mode. A top-level navigation — typing a URL, clicking a link, submitting a form, a back/forward traversal — arrives at the worker with request.mode === 'navigate'. That flag is how the worker distinguishes “the user is trying to reach a page” from “the page is fetching a sub-resource”, and it is the hook on which navigation routing hangs. Everything else — assets, data, third-party requests — is routed by URL, destination, or method. The caches object (the global CacheStorage) is the durable store the worker reads from and writes to; a Cache is a named collection of request/response pairs that survives restarts, updates, and offline sessions until you explicitly evict it.
Browser Primitives & Spec Reference
Offline routing is built on three specifications working together: the Service Workers spec (the worker, its lifecycle, and the fetch event), the Cache API (durable request/response storage), and the Fetch spec (the Request and Response objects that flow between them). Understanding their exact semantics — especially the lifecycle — prevents the most common and most confusing offline bugs.
Registration and scope. A page registers a worker with navigator.serviceWorker.register(url, { scope }). The worker can only control pages at or below its scope, and the scope defaults to the directory the worker script is served from. A worker at /sw.js controls the whole origin; a worker at /app/sw.js controls only /app/ and below. This is why the worker script almost always lives at the origin root — it is the one place that can intercept every navigation.
The lifecycle: install → activate → fetch. Registration kicks off a strict, staged lifecycle:
installfires once per worker version. This is where you precache the app shell by opening a named cache and callingcache.addAll([...]). Wrap that work inevent.waitUntil(promise)so the browser does not consider installation complete — and therefore does not advance the worker — until the cache is populated. If any request inaddAllfails, installation fails and the old worker stays in charge.activatefires when the new worker takes over. It is the correct and only safe place to delete stale caches from previous versions, because at this point no page is still being served by the outgoing worker. Again, guard the cleanup withevent.waitUntil.fetchfires for every request a controlled page makes. Callevent.respondWith(responseOrPromise)synchronously — before the handler returns — to take ownership of the request; anything you return becomes the response. Decline (return without callingrespondWith) and the request goes to the network normally.
Controlling the timing. By default a new worker waits: it installs, then sits in a waiting state until every tab controlled by the old worker has closed, and it does not control pages that were already open when it registered. Two methods override this deliberately. self.skipWaiting(), called in install, promotes the new worker past the waiting state immediately. self.clients.claim(), called in activate, lets the freshly activated worker take control of already-open pages that loaded without a controller. Used together they make the very first visit controllable and make updates apply promptly — but they demand care, because forcing a new worker on a page whose assets were served by the old one can produce version mismatches. That tension is the crux of cache versioning, covered under app shell caching.
The Cache API. caches.open(name) resolves to a Cache. Its core methods are cache.put(request, response), cache.add(request) and cache.addAll(requests) (fetch-and-store in one step), and cache.match(request) / caches.match(request) (look up a stored response, optionally across all caches). Responses are cloned on store, and a Response body is a stream that can only be read once — so when you both cache and return a network response you must response.clone() first. The cache is durable across sessions and is never evicted by the worker automatically; you own its lifecycle through explicit versioned cache names.
Navigation Preload. A worker that runs network-first for navigations adds startup latency: the browser must boot the (possibly stopped) worker before the navigation fetch can even begin. Navigation Preload removes that penalty. Enabling self.registration.navigationPreload.enable() in activate lets the browser start the navigation network request in parallel with worker startup; the in-flight response is exposed to the fetch handler as event.preloadResponse. You await that promise instead of issuing a fresh fetch, reclaiming the round trip the worker boot would otherwise have cost. It is the single most important optimisation for network-first navigation routing.
Architecture Overview
The heart of an offline-routing service worker is the fetch handler, and its first responsibility is to route by request type. Navigations, static assets, and API calls each want a different strategy, so the handler branches on request.mode, request.destination, and request.method before choosing where the response comes from. The single most consequential branch is the navigation branch: request.mode === 'navigate' isolates top-level page loads, and for content that should stay fresh the right default is network-first with a cache-then-offline-page fallback. The annotated handler below implements exactly that, with Navigation Preload wired in.
// sw.ts — TypeScript 5.x, compiled for the ServiceWorker global scope, no runtime deps
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
const VERSION = 'v3';
const SHELL_CACHE = `shell-${VERSION}`;
const OFFLINE_URL = '/offline.html';
// Precache the app shell and the offline fallback on install.
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(SHELL_CACHE).then((cache) =>
cache.addAll(['/', '/app.css', '/app.js', OFFLINE_URL]),
),
);
self.skipWaiting(); // apply the new worker without waiting for tabs to close
});
// Enable navigation preload and evict old caches once we take over.
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
const names = await caches.keys();
await Promise.all(
names.filter((n) => n !== SHELL_CACHE).map((n) => caches.delete(n)),
);
await self.clients.claim(); // control pages opened before this worker
})(),
);
});
self.addEventListener('fetch', (event) => {
const { request } = event;
// Only navigations are routed network-first with an offline fallback.
if (request.mode === 'navigate') {
event.respondWith(handleNavigation(event));
return;
}
// Non-navigation requests fall through to a separate strategy or the network.
// (Assets are handled cache-first elsewhere; see the strategies guide.)
});
async function handleNavigation(event: FetchEvent): Promise<Response> {
try {
// 1. Prefer the preloaded response the browser started in parallel.
const preload = await event.preloadResponse;
if (preload) return preload as Response;
// 2. Otherwise go to the network first for fresh content.
const fresh = await fetch(event.request);
return fresh;
} catch {
// 3. Network unreachable: serve the cached shell, then the offline page.
const cache = await caches.open(SHELL_CACHE);
const cachedShell = await cache.match('/');
return cachedShell ?? (await cache.match(OFFLINE_URL))!;
}
}
Several deliberate choices in this handler are worth drawing out. respondWith is called synchronously inside the event listener — deferring it to a later microtask forfeits control of the request, so the branching decision must happen up front even though the response itself resolves asynchronously. The navigation branch awaits event.preloadResponse first: when Navigation Preload is enabled the browser has already started the network fetch, and awaiting a fresh fetch() instead would waste that parallel work. The catch block is the routing fallback proper — it fires only when the network genuinely fails, at which point the worker serves the cached application shell so the client router can boot and, if even that is missing, the dedicated offline page. Because the handler declines every non-navigation request (it returns without calling respondWith), assets and API calls flow to their own strategy or straight to the network; a real worker composes this navigation route with the cache-first and stale-while-revalidate routes described under routing strategies.
Notice what the worker does not do. It never rewrites the URL or performs client-side route matching — that remains the job of the in-page router built on the History API. The worker’s concern is strictly which bytes answer a request, not which component renders inside the document. Keeping that separation clean is what lets the same offline-routing layer sit beneath a React, Vue, or SvelteKit application unchanged: the framework owns navigation within the shell, the worker owns delivery of the shell and its data.
Performance & SEO Implications
The most important SEO fact about service workers is also the most frequently forgotten: the worker does not run for a crawler’s first fetch of a URL. A search engine requesting a page it has never seen receives the origin’s actual server response, because no worker is installed in that fresh context — registration only happens after a real user’s browser has loaded and executed the registering page at least once. The practical consequence is liberating: your offline-routing layer cannot mask a broken origin from crawlers, and it cannot substitute cached content for what the server returns to a bot. Correct SEO therefore still depends entirely on the origin serving proper HTML, status codes, and metadata for every route, exactly as covered in the client-side routing architecture fundamentals. Treat the service worker as an enhancement for returning users, never as the source of truth for discovery.
For real users, the worker is a decisive performance lever. A precached app shell served cache-first eliminates the network from the critical path on repeat visits, so LCP is bounded by disk read and paint rather than round-trip latency — often the difference between a good and a poor score on flaky connections. Navigation Preload protects the network-first path from the worst-case worker-boot penalty by overlapping worker startup with the navigation fetch, keeping TTFB honest even when the worker had stopped between visits. Stale-while-revalidate on the shell gives an instant paint from cache while a background fetch refreshes the entry for next time, trading a small staleness window for the fastest possible response now.
There are costs to weigh. A worker that caches too aggressively can pin a stale build in front of users indefinitely, so cache versioning and a disciplined activate cleanup are not optional. Precaching a large shell delays the first install and consumes storage that the browser may later reclaim under pressure, evicting your caches without warning — so the worker must always tolerate a cache miss and fall back to the network. And because the worker runs on its own thread, its startup and its strategy logic are off the main thread, meaning INP is unaffected by routing decisions there; the main-thread cost is confined to registration and the client router, not the interception.
Accessibility Considerations
Offline state is a status change the user must be able to perceive, and for assistive-technology users perceiving it requires deliberate work. When a navigation falls through to the cached shell or the offline page, sighted users may notice a subtly different screen, but a screen-reader user receives no signal unless you provide one.
- Announce the transition to offline. When the worker serves an offline fallback, the page it renders should carry the offline message in an ARIA live region (
aria-live="polite") or as the page’s main heading, so the destination is announced on load rather than silently swapped in. The offline page is a real navigation destination and should be built to the same accessibility standard as any route. - Reflect connectivity in the running app. For same-document navigations that fail while the app is already open, update a persistent status region from the page using the
online/offlineevents, and write a concise, human message (“You are offline — showing saved content”) into a polite live region. Do not rely on colour or an icon alone. - Keep the offline page keyboard-complete. A “retry” affordance on the offline page must be a real, focusable button with an accessible name, and focus should move to the main landmark when the page loads — the same post-navigation focus discipline the client routing architecture requires, applied to the fallback.
- Do not trap or auto-refresh silently. Reloading the moment connectivity returns can yank a screen-reader user out of context mid-sentence; offer the reload, announce it, and let the user trigger it.
Done well, offline announcements satisfy the WCAG 2.1 AA status-message criterion and turn a confusing dead end into a state the user understands and can act on.
Common Pitfalls & Edge Cases
- Scope too narrow. A worker registered from a sub-path (
/app/sw.js) cannot intercept navigations outside its scope; serve the worker from the origin root so it can route every page. - Stale cache pinned in front of users. Without versioned cache names and an
activatecleanup, an old shell can outlive many deploys. Bump the version on every content change to the precached shell and delete non-current caches on activate. - Reading a
Responsebody twice. A body is a single-use stream; cache and return the same network response withoutresponse.clone()and one of the two consumers gets an empty body. skipWaitingwithout a reload plan. Forcing a new worker viaskipWaiting()while a page still holds references to old, now-evicted asset URLs causes mismatched-version failures. Coordinate updates — prompt the user to reload, or scopeskipWaitingto safe cases.- Caching non-OK or opaque responses. Storing a
404, a5xx, or an opaque cross-origin response as if it were valid serves errors from cache later. Checkresponse.ok(andresponse.type) beforecache.put. - Assuming the worker runs for crawlers or the first visit. It runs for neither; the origin must serve correct HTML and status codes independently.
unload/beforeunloadhandlers evicting bfcache. Registering anunloadhandler (a habit from analytics and cleanup code) makes the page ineligible for the browser’s back/forward cache, so a back navigation reloads from scratch instead of restoring instantly — undoing much of what your caching bought. Preferpagehide/visibilitychangeand keep the page bfcache-eligible.- Forgetting the offline branch on sub-resources. Handling only navigations while assets fail silently leaves a shell that renders but cannot hydrate; route asset requests through a cache-first strategy with a network fallback too.
Browser & Runtime Compatibility
| Feature | Chrome | Firefox | Safari | Edge | Node SSR |
|---|---|---|---|---|---|
Service Worker registration & fetch event |
Yes | Yes | Yes | Yes | N/A (no worker) |
Cache Storage API (caches) |
Yes | Yes | Yes | Yes | N/A |
skipWaiting / clients.claim |
Yes | Yes | Yes | Yes | N/A |
| Navigation Preload | Yes | Yes | Partial | Yes | N/A |
request.mode === 'navigate' |
Yes | Yes | Yes | Yes | N/A |
| Background Sync (deferred retries) | Yes | No | No | Yes | N/A |
Service workers require a secure context (HTTPS, or localhost for development) and are unavailable inside private-browsing modes in some browsers. On the server there is no worker at all: SSR renders the origin response the crawler and the first visitor receive, and the worker only enters the picture on subsequent client navigations. Feature-detect Navigation Preload (if (self.registration.navigationPreload)) and Background Sync before relying on either, and always provide a network fallback so a browser without a given feature — or a user whose cache was evicted — still reaches the content.
Explore the Topics
- Service Worker Routing Strategies — When to route a request cache-first, network-first, or stale-while-revalidate, and how to match each strategy to navigations, assets, and API calls.
- Offline Fallback Pages — Building and wiring a hand-authored offline page so failed navigations land on a branded, accessible screen instead of the browser’s error.
- App Shell Caching — Precaching the unchanging SPA shell, versioning caches safely, and serving the shell instantly so an installed web app feels native offline.
Related
- Routing Architecture & Fundamentals — The client-side routing layer that runs inside the shell the service worker delivers, including fallback routing strategies for unmatched routes.
- History API & State Management — The navigation primitives the in-page router uses to swap content into the cached shell without a document reload.
- Fallback Routing Strategies — The in-application counterpart to the worker’s offline fallback: catching unmatched routes and degrading gracefully.
- App Shell Caching — Go deeper on precaching and versioning the shell that underpins every offline navigation.