App Shell Caching

The app-shell model splits a single-page application into two halves: a static shell — the HTML skeleton, critical CSS, and the JavaScript that boots your router — and the dynamic content that shell later fetches and renders. Cache the shell once in a service worker and every client-side route can paint its chrome instantly, even offline, while the data for that route loads in parallel. This page shows how to precache the shell correctly, keep it separate from your runtime and data caches, invalidate it cleanly on deploy, and lean on Navigation Preload so the shell never delays the network.

← Back to Service Worker & Offline Routing

The Problem

A client-rendered SPA has an awkward first frame. The server returns a near-empty HTML document, the browser downloads and parses the bundle, the router reads location.pathname, and only then does anything meaningful appear. On a warm connection that is a few hundred milliseconds; on a cold or offline one it is a blank white screen or the browser’s own error page. Worse, because a SPA owns every URL under its origin, a hard refresh on /settings/billing hits the network for a document that only ever existed as index.html — and if the network is gone, the navigation fails outright.

The naive fix is to cache every navigation response. That breaks immediately for a client-rendered app, because there is no distinct server-rendered document per route to cache — every route is the same shell with different JavaScript-driven content. Caching per-URL either stores hundreds of identical copies of the shell under different keys or, if the server does return route-specific HTML, silently mixes stale server output with fresh client rendering. Either way the cache balloons and the semantics blur.

The app-shell model resolves this by inverting the unit of caching. You cache the shell once, under a single stable key, and configure the service worker to answer every navigation request with that one cached shell regardless of the requested path. The router inside the shell then reads the real URL and renders the matching view. Dynamic content — API responses, user data, images — lives in a completely separate cache with its own freshness policy. This is the same trade-off examined in SPA vs MPA tradeoffs: you are choosing to serve one cheap, cacheable shell and hydrate on the client rather than render a bespoke document per route on the server. Getting the shell into the cache and keeping it correct across deploys is what the rest of this page is about, and it builds directly on the service worker routing strategies that decide how each request class is answered.

App shell caching request flow A navigation request is answered from the precached shell cache while data requests go to a separate runtime cache, and both fall back to the network. Navigation any client route Service Worker fetch handler Shell cache precached, versioned Runtime / data cache API, images shell data
Navigations resolve to the single precached shell; data requests are routed to a separate runtime cache with its own freshness policy.

Core API & Primitives

App-shell caching rests on three service worker primitives, each with a precise contract.

The CacheStorage interface (caches) is a named collection of Cache objects. Each Cache maps a Request to a Response. You choose the names, and the names are how you version — a new shell version means a new cache name, not an in-place mutation of an existing one.

The install and activate lifecycle events give you the two windows you need. install is where you populate the new shell cache before the worker is allowed to take over; activate is where you delete the caches belonging to superseded versions. A waitUntil on either event holds the lifecycle open until your promise settles.

Navigation Preload lets the browser start the network fetch for a navigation in parallel with the service worker booting, so a cold worker start does not add latency on the one request that matters most. You enable it in activate and read the result in the fetch handler.

// TypeScript 5.x — service worker global scope, no runtime dependencies
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;

// A single content-hashed version string ties all shell caches together.
const SHELL_VERSION = "shell-v42";
const SHELL_CACHE = `${SHELL_VERSION}`;
const DATA_CACHE = "runtime-data-v1"; // versioned independently of the shell

// The precache manifest: the minimal set of assets that render the chrome.
const SHELL_ASSETS: readonly string[] = [
  "/app-shell.html", // the skeleton document, NOT a route-specific page
  "/assets/app.[hash].css",
  "/assets/app.[hash].js",
];

interface ShellStrategy {
  readonly cacheName: string;
  matches(request: Request): boolean; // true for navigations
  respond(event: FetchEvent): Promise<Response>;
}

The critical design point encoded above: SHELL_CACHE carries a version in its name and DATA_CACHE does not share it. The shell rev’s on every deploy; the data cache persists across deploys because user data has nothing to do with which shell build is live.

Step-by-Step Implementation

Prerequisite: a build that emits a standalone shell document (app-shell.html) containing your mount point, critical inline CSS, and the bootstrap script tags, plus content-hashed asset filenames. Register the worker from your entry script with navigator.serviceWorker.register("/sw.js") after first paint so registration never competes with the initial render.

Step 1: Precache the shell on install

In the install handler, open the versioned shell cache and add every asset in the manifest atomically. cache.addAll rejects if any request fails, which is exactly what you want — a half-populated shell cache is worse than none. Call skipWaiting only after the cache is populated so the new worker never activates against an incomplete shell.

// TypeScript 5.x — service worker, install phase
self.addEventListener("install", (event: ExtendableEvent) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(SHELL_CACHE);
      // Atomic: if one asset 404s, the whole install fails and the old
      // worker stays live — you never ship a broken shell.
      await cache.addAll(SHELL_ASSETS);
      // Take over on next navigation rather than waiting for all tabs to close.
      await self.skipWaiting();
    })(),
  );
});

Step 2: Enable Navigation Preload and purge old caches on activate

When the worker activates, turn on Navigation Preload so the browser can race the network against the worker’s cold start, then delete every shell cache whose name is not the current version. Deleting the data cache here would be a bug — it is keyed independently and must survive the deploy.

// TypeScript 5.x — service worker, activate phase
self.addEventListener("activate", (event: ExtendableEvent) => {
  event.waitUntil(
    (async () => {
      if (self.registration.navigationPreload) {
        await self.registration.navigationPreload.enable();
      }
      const keys = await caches.keys();
      await Promise.all(
        keys
          // Keep the current shell and the (unversioned) data cache.
          .filter((k) => k !== SHELL_CACHE && k !== DATA_CACHE)
          .filter((k) => k.startsWith("shell-")) // only prune stale shells
          .map((k) => caches.delete(k)),
      );
      await self.clients.claim(); // control open pages immediately
    })(),
  );
});

Step 3: Answer every navigation from the shell cache

The fetch handler is where the app-shell model becomes real. Detect navigation requests via request.mode === "navigate" and answer all of them with the one precached shell — never with a per-URL cache lookup. Consume the preload response only as a fallback when the shell somehow is not cached, so a healthy cache never waits on the network.

// TypeScript 5.x — service worker, fetch phase
self.addEventListener("fetch", (event: FetchEvent) => {
  const { request } = event;

  // Only navigations get the shell; data and assets are handled elsewhere.
  if (request.mode === "navigate") {
    event.respondWith(
      (async () => {
        const cache = await caches.open(SHELL_CACHE);
        const shell = await cache.match("/app-shell.html");
        if (shell) return shell; // instant paint, offline-safe

        // Cold-cache fallback: use the preloaded network response if present.
        const preloaded = await event.preloadResponse;
        if (preloaded) return preloaded as Response;
        return fetch("/app-shell.html");
      })(),
    );
  }
});

Step 4: Route data and asset requests to a separate cache

Non-navigation requests must never touch the shell cache. Send hashed static assets to the shell cache (they were precached) and API calls to the data cache under a strategy of your choosing — network-first for freshness, or the stale-while-revalidate approach for the app shell when you want an instant response backed by a background refresh.

// TypeScript 5.x — service worker, non-navigation branch of the fetch handler
async function handleData(request: Request): Promise<Response> {
  const cache = await caches.open(DATA_CACHE);
  try {
    const fresh = await fetch(request);
    // Only cache successful, basic/cors responses; never opaque errors.
    if (fresh.ok) await cache.put(request, fresh.clone());
    return fresh;
  } catch {
    const cached = await cache.match(request);
    if (cached) return cached; // offline: serve last-known data
    throw new Error("offline and uncached");
  }
}

Verification & Testing

Verify two independent properties: that the shell is genuinely served from cache (not the network) and that an offline navigation to a deep route still paints. Drive both through a real service worker rather than mocking caches, because the lifecycle timing is where bugs hide.

// @playwright/test v1.44 — service worker + offline navigation
import { test, expect } from "@playwright/test";

test("deep route paints offline from the precached shell", async ({ page, context }) => {
  await page.goto("/"); // registers and activates the worker
  await page.waitForFunction(() => navigator.serviceWorker.controller !== null);

  await context.setOffline(true);
  // A hard navigation to a route that has no server document.
  await page.goto("/settings/billing");

  // The shell mounted and the router rendered the view — offline.
  await expect(page.locator("[data-app-shell]")).toBeVisible();
  await expect(page).toHaveURL(/\/settings\/billing$/);
});

In DevTools, open Application → Cache Storage and confirm exactly one shell-v* cache holds your manifest assets, then use the Network panel’s “Size” column: a shell served from the service worker reads (ServiceWorker), not a transfer size. Throttle to Offline and reload a deep route — a correct setup repaints instantly.

Performance Tuning

  • Optimise LCP from cache, not the network. Because the shell serves in single-digit milliseconds, your Largest Contentful Paint is gated by what renders inside the shell. Inline critical CSS into app-shell.html so the chrome has no render-blocking round-trip, and reserve space for the LCP element to avoid a layout shift when data arrives.
  • Keep the precache manifest minimal. Every byte in SHELL_ASSETS is downloaded on install before the worker activates. Precache only what paints the chrome; let route-level bundles load on demand through route-based code splitting.
  • Let Navigation Preload cover cold starts. On a killed worker, boot latency can add tens of milliseconds to the first navigation. Preload runs that fetch concurrently so the penalty is hidden even before the cache answers.
  • Version the data cache on its own cadence. Bumping DATA_CACHE only when your API response shape changes avoids needlessly re-fetching user data on every shell deploy.
  • Prefer one shell document over many. A single /app-shell.html keyed once beats caching each navigation URL: the cache stays tiny and the hit rate is effectively 100% across all routes.

Gotchas & Failure Modes

  • Stale shell after deploy. If the shell cache name does not change, the install handler’s addAll writes into the same cache and browsers may serve the old entries. Always fold a content hash or build id into SHELL_VERSION so a new build is a new cache, and prune the old one in activate.
  • Cache-busting the wrong layer. Query-string cache-busting on the shell HTML does nothing once the service worker intercepts navigations — the worker answers from cache before the URL’s query is ever considered. Bust by version in the cache name, not in the URL.
  • skipWaiting shipping mismatched assets. Calling skipWaiting lets a new worker control a page whose already-loaded JavaScript expects the old asset hashes. Precache the full matched set atomically and consider prompting the user to reload rather than swapping under a live session.
  • Precaching a route-specific document. If app-shell.html is accidentally a server-rendered page for /, every route paints the home view’s content before hydrating. The precached document must be route-neutral chrome only.
  • Opaque responses poisoning the cache. cache.put on a cross-origin no-cors response stores an opaque body you cannot inspect and that counts fully against quota. Guard every put with a response.ok check as shown above.
  • Deleting the data cache in activate. A too-eager cleanup that removes anything not equal to SHELL_CACHE wipes user data on every deploy. Scope the prune to shell- prefixed names.

Go Deeper

  • Precaching the SPA Shell — a focused walkthrough of the precache manifest, install-time cache.addAll, and serving the shell for every navigation, with versioned-cache cleanup on activate.