The app shell - the minimal frame of the interface - must appear instantly and work with no network. Runtime caching, which fills up as the user moves through the app, leaves the very first offline visit empty: there is nothing in the cache to serve yet. The key observation is that the build knows the exact set of app shell files better than the runtime does - at build time the list is fully known, with no guessing.
Precache is a list of files that the service worker downloads and stores at install and versions together with itself. On install the worker fetches them all at once; on update it re-syncs the ones that changed. That is exactly why the shell is available on the very first offline load, rather than only after the user happened to open the right screen while online.
A URL with a content hash - say app.4f3a2b.js - identifies its content by itself: change the file and the URL changes. For addresses without a hash (index.html, /manifest.webmanifest) you need a revision string so the precache knows the content changed. The list must not be written by hand: a build-tool integration includes every chunk automatically and will not miss a forgotten one. The plugin injects the manifest into the __WB_MANIFEST variable, and precacheAndRoute stores it and serves it.
Workbox has two modes. generateSW generates the whole worker - simpler, and a fit for a standard app that only needs caching. injectManifest is chosen when you need your own worker: IndexedDB and an outbox, push, complex routing, messaging with the page, custom telemetry. Our notes app needs an outbox, so it uses injectManifest only - Workbox merely injects the manifest into the code you wrote.
Navigation is a separate problem. A navigation request is the browser asking for a top-level HTML document, that is, following a URL. Offline that request fails, and you want to serve the prepared shell. But the offline shell must not mask a real 404 and must not turn every link into an SPA - otherwise the user can no longer tell a genuinely missing page from a temporarily unavailable one.
For an SPA the navigation fallback often returns the precached index.html, after which the client router restores the right screen from the URL. For SSR or MPA it is better to use Network First with a cached page or a dedicated offline.html. API, admin, auth callback and files must be excluded from the fallback - they have no business acting as an HTML shell. The handler tries the network, and on error falls back to the cache and then to the offline page.
navigationPreload starts the network request in parallel with the service worker's startup, so you don't pay for booting the worker before the request even begins; event.preloadResponse is that very response, already in flight. While the network is up, it or a plain fetch is returned. The moment the network is gone, catch falls to caches.match and, as a last resort, to /offline.html.
The offline page must be genuinely self-contained: critical CSS inline or in precached dependencies, a clear explanation, a list of the sections available offline, and a retry button. You must not show fake server content that pretends the data loaded. For the notes app, offline.html lists the notes already sitting in the local cache and lets the user open them - honest offline, not an imitation of online.
// sw.ts with Workbox injectManifest
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
declare let self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: Array<{ url: string; revision?: string }>
};
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();async function handleNavigation(event) {
try {
const preload = await event.preloadResponse;
if (preload) return preload;
return await fetch(event.request);
} catch {
return (await caches.match(event.request)) ??
(await caches.match('/offline.html'));
}
}
self.addEventListener('activate', event => {
event.waitUntil(self.registration.navigationPreload?.enable());
});