Web Platform · Progressive Web
Offline Navigation, App Shell, and Fallback Routing
Why navigation requests need their own handling in a service worker, the trade between shell-based and per-URL caching, and how navigation preload removes the worker-startup delay from the critical path.
Typing a URL, following a bookmark, and pressing reload all produce a navigation request. It differs from a subresource request in one respect that shapes every offline design: the request URL becomes the document’s address. Whatever body the worker supplies, the browser shows the requested URL, resolves relative URLs against it, and stores history entries against it. Serving one cached shell document for every path therefore gives the shell the address of a deep link while the body knows nothing about that link.
Why navigation cannot be treated as another fetch
Three properties distinguish navigations. The request’s mode is 'navigate' and its destination is 'document', so they can be selected with a single predicate. They may be POST requests, and a cached GET response cannot stand in for one; replaying a form submission from a worker would require re-sending a body the cache never stored. And the response is interpreted as a document, which means its Content-Type decides rendering and its redirects rewrite the address bar rather than being followed invisibly.
A reload is marked on the request as cache: 'reload', which tells the HTTP cache to revalidate. A handler that ignores it and returns a stale cache entry defeats the user’s explicit request for fresh bytes; a handler that honours it and always goes to the network defeats offline support, because reload while offline then fails even though the page is cached. The usual resolution is to treat reload as network-first with cache fallback, accepting a delay for correctness.
Two shapes of offline navigation
The app shell model precaches a minimal HTML document, a stylesheet, a script bundle, and a fallback page during install, and answers every in-scope navigation from that document. A client-side router reads location.pathname after the shell boots and renders the matching view. The advantage is that one cached document serves every route, so the precache size is independent of the number of pages, and a new route works offline without a deployment. The cost is a delay: the shell must download and execute before any route-specific content exists, and the browser paints an empty document in the meantime unless the shell includes meaningful static markup.
The per-URL model caches each document as it is visited. A server-rendered site stores /reports/2025 under its own URL, and the fetch handler serves that exact response when the same URL is requested offline. The advantage is fidelity: the address matches the content, first paint contains real text, and no router is needed. The cost is coverage — only pages that have been visited are available, and each cached document carries its own metadata, so the storage grows with the size of the visited set rather than staying constant.
Both models can coexist. A site with server-rendered pages and an interactive editor commonly caches those routes individually and precaches the editor shell, choosing per URL prefix rather than applying one policy to all navigations.
self.addEventListener('fetch', (event) => {
// Only GET navigations can be answered by a cached document.
if (event.request.mode !== 'navigate' || event.request.method !== 'GET') return;
event.respondWith((async () => {
const cached = await caches.match(event.request, { ignoreSearch: false });
if (cached) return cached; // Exact URL, already visited.
const shell = await caches.match('/shell.html'); // Any other in-scope route.
return shell || new Response('Offline', {
status: 503,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
});
})());
});
The order matters. Matching the exact URL first preserves deep-link content where it exists; falling back to the shell covers routes never visited. With ignoreSearch: true on the first lookup, /reports/2025?page=2 would be answered by /reports/2025, which is correct for a document whose query only affects client-rendered state and wrong for one whose query selects a different page.
Navigation preload and the startup tax
A worker that has been terminated must be started before it can answer a fetch event. That start includes fetching and evaluating the script, which for a large bundle is work on the critical path of the navigation, before the handler’s own cache lookup or network request begins. Navigation preload removes the serialization: with the feature enabled, the browser starts the navigation request in parallel with worker startup, and the handler retrieves the in-flight response through event.preloadResponse.
self.addEventListener('activate', (event) => {
event.waitUntil(self.registration.navigationPreload.enable());
});
self.addEventListener('fetch', (event) => {
if (event.request.mode !== 'navigate' || event.request.method !== 'GET') return;
event.respondWith((async () => {
try {
const preloaded = await event.preloadResponse; // undefined if disabled
if (preloaded) return preloaded;
} catch { /* preload failed; fall through to cache */ }
return (await caches.match(event.request)) || Response.error();
})());
});
The preload request is an ordinary navigation request sent by the browser, marked with a Service-Worker-Navigation-Preload: true header so the server can distinguish it and, for example, omit a personalized header fragment. setHeaderValue() replaces the header’s value with a string of the application’s choosing, which lets a server vary its response for preloaded navigations; getState() reports whether preloading is enabled and what value the header carries.
Two limits follow from the mechanism. Preload helps only navigations, not subresources, so a page whose critical CSS is fetched by the handler gains nothing from it. And a preloaded response is wasted if the handler answers from cache anyway, since the request has already been sent; a cache-first navigation handler that also enables preload pays for a network request it discards. Preload suits network-first navigation strategies and strategies that race the network against a timeout.
Fallbacks that preserve meaning
An offline fallback is a document served when nothing else can answer. The choice of status code carries the meaning: 200 with a custom offline page tells the browser and any intermediate tooling that the navigation succeeded, while 503 reports that the resource is temporarily unavailable, which is accurate and lets monitoring distinguish it. Browsers render a 503 body like any other document; there is no special error page for it.
The fallback document is subject to the same URL rule as a shell. Served at /invoices/2024/07, its relative asset references resolve under /invoices/2024/, so the offline page’s own stylesheet and retry button must use root-absolute paths or the page arrives unstyled and the button does nothing. The same applies to a shell: an inline router that reads location.pathname sees the deep path, and links rendered by the router must include the base path if the app is not mounted at the origin root.
Redirects interact with this badly. Returning Response.redirect('/login', 302) for an offline navigation rewrites the address bar to /login and creates a history entry; returning it for an API call produces a fetch that may follow it into a document. A handler that wants to send an unauthenticated user to a login screen while offline is better served by rendering the login screen in place, at the requested URL, than by redirecting.
Connectivity signals are weaker than they appear. navigator.onLine reports whether the device has a network interface with a route, not whether a request will succeed: a captive portal, a broken upstream, or a firewall that drops traffic all leave the flag set. A handler that checks the flag before deciding to go to the network therefore takes the network path on a hotel network that answers every request with a login page. The reliable signal is the fetch outcome, so the strategy is to attempt the request, catch the rejection, and fall back, with the online event used only to trigger a retry of a pending queue rather than to gate the attempt.
Consistency with the installed application
An installed application launched from its icon issues a navigation to start_url, which passes through the same handler. If the manifest declares a scope of /app/ and the worker’s shell is cached under /, an offline launch of an installed instance can land on a shell whose router does not know the /app/ prefix. Keeping the manifest start_url, its scope, and the worker’s precache paths derived from one base path avoids the class of bug where an app works in a tab and fails from the launcher; the relationship between the two configurations is set out in Web App Manifest and the Installability Contract.
Offline navigation is only the visible part of the problem. The responses behind it live in caches that a browser may evict, and the rules governing that eviction are covered in Storage Quota, Persistence, and Eviction.
References
- Service Workers Nightly — W3C Web Applications Working Group
- Fetch Standard — Request destinations — WHATWG
- NavigationPreloadManager — MDN Web Docs