Precaching the SPA Shell
After reading this you will be able to declare a precache manifest of your shell assets, store them at service worker install time with a single cache.addAll, and serve that one shell for every navigation a client-rendered SPA makes — so any route paints instantly, even with no network.
← Back to App Shell Caching
Prerequisites
Core Concept
Precaching means fetching and storing a known, fixed set of assets before they are ever requested — at install time, while the worker is still in the background and the current page is untouched. For a client-rendered SPA the fixed set is the app shell: the HTML skeleton, the critical CSS, and the JavaScript that boots your router. Because every URL under the origin resolves to the same shell (the router picks the view on the client), you precache exactly one HTML document and answer all navigations with it. This is what lets a cold, offline reload of a deep route like /reports/2026/q3 paint immediately: the service worker returns the cached shell, the bundle boots from cache, and the router reads the real pathname and renders. The manifest is the contract — an explicit, versioned list of what “the shell” is — and cache.addAll makes populating it atomic so you never activate against a half-stored shell.
Implementation
The worker below declares a manifest under a version-stamped cache name, precaches it atomically on install, cleans up superseded shell caches on activate, and serves the single shell document for every navigation.
// TypeScript 5.x — service worker global scope, no runtime dependencies
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
// The version string is the whole cache-invalidation strategy: change it
// on every build (inject a content hash at bundle time) and the old shell
// cache becomes a distinct, prunable entry.
const SHELL_CACHE = "spa-shell-v7";
const SHELL_DOC = "/app-shell.html";
// The precache manifest: the minimal asset set that renders the chrome.
const PRECACHE_MANIFEST: readonly string[] = [
SHELL_DOC,
"/assets/app.4f2a1c.css",
"/assets/app.9b7e30.js",
];
self.addEventListener("install", (event: ExtendableEvent) => {
event.waitUntil(
(async () => {
const cache = await caches.open(SHELL_CACHE);
// Atomic: any 404 rejects the whole addAll, aborting the install so
// the previous worker keeps serving a complete shell.
await cache.addAll(PRECACHE_MANIFEST);
await self.skipWaiting();
})(),
);
});
self.addEventListener("activate", (event: ExtendableEvent) => {
event.waitUntil(
(async () => {
// Delete only prior shell caches; leave any runtime/data cache alone.
const names = await caches.keys();
await Promise.all(
names
.filter((n) => n.startsWith("spa-shell-") && n !== SHELL_CACHE)
.map((n) => caches.delete(n)),
);
await self.clients.claim();
})(),
);
});
self.addEventListener("fetch", (event: FetchEvent) => {
// Serve the one precached shell for every navigation, regardless of path.
if (event.request.mode === "navigate") {
event.respondWith(
(async () => {
const cache = await caches.open(SHELL_CACHE);
const shell = await cache.match(SHELL_DOC);
return shell ?? fetch(event.request); // network only if uncached
})(),
);
}
});
The two load-bearing lines are cache.match(SHELL_DOC) — which looks up the shell by its fixed key rather than by the requested URL — and the request.mode === "navigate" guard, which ensures only navigations get the shell while asset and data requests fall through to whatever routing strategy you layer on next.
Verification
Confirm the shell is genuinely precached and that an offline deep-route navigation paints from it, using a real worker rather than a stubbed caches.
// @playwright/test v1.44 — precache + offline navigation
import { test, expect } from "@playwright/test";
test("shell is precached at install and served offline", async ({ page, context }) => {
await page.goto("/");
await page.waitForFunction(() => navigator.serviceWorker.controller !== null);
// The shell document is present in the versioned cache.
const cached = await page.evaluate(async () => {
const cache = await caches.open("spa-shell-v7");
return (await cache.match("/app-shell.html")) !== undefined;
});
expect(cached).toBe(true);
await context.setOffline(true);
await page.goto("/reports/2026/q3"); // no server document for this URL
await expect(page.locator("[data-app-shell]")).toBeVisible();
});
For a manual check, open DevTools → Application → Cache Storage, confirm spa-shell-v7 contains every manifest entry, then switch the Network panel to Offline and reload a deep route: it should repaint instantly with no network requests for the document.
Gotchas
- Unversioned cache names never invalidate.
cache.addAllinto a fixed name may leave stale entries in place across deploys. Put a content hash or build id in the cache name so each build is a new cache, and prune the old one inactivate. - Skipping the activate cleanup. Without the prune step, every deploy leaves another
spa-shell-*cache behind, quietly consuming storage quota until eviction. Always delete non-current shell caches onactivate. - Precaching a rendered home page. If
app-shell.htmlis really the server-rendered/view, every route flashes the home content before the router corrects it. The precached document must be route-neutral chrome. skipWaitingunder a live session. Activating immediately can pair a new shell with already-running old JavaScript that expects the previous asset hashes; precache the whole matched set atomically, and for critical apps prompt a reload rather than swap silently.
FAQ
What belongs in the precache manifest? Only the assets required to render the shell chrome and boot the router — the shell HTML, critical CSS, and the entry JavaScript. Route-specific bundles, images, and API data should load on demand and live in a separate runtime cache, not the precache.
Why use cache.addAll instead of caching assets individually? addAll is atomic: if any single request fails, the whole operation rejects and the install fails, so you never activate a worker against a partially stored shell. Individual puts can leave the cache in a broken, half-populated state.
How do I serve the same shell for every route? In the fetch handler, detect navigation requests by checking that request mode equals navigate, then respond with the shell document matched by its fixed cache key rather than by the requested URL. The client router then reads the real path and renders the view.
How does precaching invalidate on a new deploy? Change the version segment of the cache name on every build, ideally by injecting a content hash. The install handler populates the new cache while the activate handler deletes the previous shell caches, so the swap is atomic and old assets are pruned.
Does precaching work for the very first visit? The service worker installs during the first load but only controls navigations afterwards, so the initial visit is served by the network. From the second navigation onward the precached shell answers instantly, including offline.
Related
- App Shell Caching — the parent guide covering shell versioning, cache separation, and Navigation Preload.
- Service Worker Routing Strategies — how to answer asset and data requests once navigations are served from the shell.
- SPA vs MPA Tradeoffs — the architectural choice between a cached client shell and per-route server rendering.