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.

Five reasons a precache install fails Five install failures: a single 404 rejecting the whole addAll, stale hashed filenames, opaque cross-origin responses, quota exhaustion, and a precache list large enough to delay activation. Why an install fails and nothing is cached cause symptom one URL in addAll 404s the whole install rejects no cache at all, silently a hashed filename is stale the manifest was not regenerated the same atomic failure an opaque cross-origin response stored, but unusable a cache hit that renders nothing storage quota exceeded the write rejects partial or empty cache the list is too large install takes seconds the worker never activates in time
addAll is atomic, which is a feature — but it means one wrong filename leaves you with no cache whatsoever.

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.

Four ways to produce a precache list Four sources for the precache manifest — hand-written, derived from the bundler manifest, globbed from the output directory, or generated by a build plugin — each with its principal risk. Generating the precache list source risk hand-written array fastest to start goes stale on the first refactor bundler manifest filenames always current must run after the build a glob over the output catches everything catches too much, including maps a build plugin (workbox-style) manifest plus revisions one more dependency
Any source except the first is acceptable; the first guarantees a stale filename within a month.

Gotchas

  • Unversioned cache names never invalidate. cache.addAll into 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 in activate.
  • 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 on activate.
  • Precaching a rendered home page. If app-shell.html is really the server-rendered / view, every route flashes the home content before the router corrects it. The precached document must be route-neutral chrome.
  • skipWaiting under 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.

One habit makes precaching reliable in the long run: never write the list by hand. Every entry contains a hashed filename that changes on the next build, so a hand-maintained array is correct exactly until the next deployment and then fails atomically — leaving the user with no cache at all rather than a partially stale one. Generating it from the bundler’s manifest, as part of the build that produced those filenames, removes the possibility entirely.

The related habit is to keep the generated list reviewed. A manifest-driven precache grows quietly as the build changes, and a shell that started at forty kilobytes can reach several hundred without anyone noticing. Printing the total size at build time, and failing the build above a threshold you have chosen deliberately, turns that drift into a decision rather than an accident.

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.

The four stages of a precached shell Four stages: the build emits a manifest of hashed filenames, install populates a versioned cache atomically, activate deletes previous versions and claims clients, and runtime caching handles route chunks as they are used. The precache lifecycle 1 · build emit a manifest hashed names + revisions 2 · install open a versioned cache addAll, atomically 3 · activate delete other versions then claim clients 4 · runtime cache route chunks on use the shell stays fixed
Only stage two is atomic — which is why stage one has to be generated rather than maintained by hand.