Skip to content

App Shell Model

The app shell model splits a Progressive Web App into two parts: a small, static shell (the HTML, CSS and JavaScript for the header, navigation and layout that every screen shares) that the service worker precaches and serves for every navigation, and the content for each screen, which the shell fetches and renders at runtime. On repeat visits the shell paints almost immediately from Cache Storage, with or without a network, which is what makes single-page PWAs launch like native apps. The same split also moves the largest content paint behind JavaScript and a data request, so an app shell is a trade: excellent First Contentful Paint and offline behavior against a Largest Contentful Paint that can be worse than a server-rendered page. This page covers when that trade is worth it, how to build it correctly, and how to measure it.

Key takeaways

  • The shell is everything that does not change between routes; it is precached during install, and the service worker answers every in-scope navigation with the same cached shell HTML, whatever the URL.
  • The model fits client-rendered single-page apps with a persistent UI, especially logged-in, interactive and offline-capable tools. It is a poor fit for content sites, pages reached from search, and multi-page apps.
  • On repeat visits, the shell makes TTFB and FCP very fast, but LCP now waits for JavaScript, a data request and client rendering. Measure the FCP-to-LCP gap; it is the shell's hidden cost.
  • Deny-list navigations that must reach the server (APIs opened directly, OAuth callbacks, file downloads, server-rendered sections), or the shell will swallow them.
  • Streaming the cached shell head together with a server-rendered content fragment (fetched through navigation preload) keeps instant first paint and puts the LCP element back into the first response.
  • Workbox (NavigationRoute with createHandlerBoundToURL, or navigateFallback), vite-plugin-pwa (whose generateSW mode defaults navigateFallback to index.html) and Angular's service worker (index and navigationUrls) all implement the app shell pattern for you; know their defaults before you ship them in front of a server-rendered site.

What an app shell is

Chrome's Workbox documentation describes the application shell as the minimal HTML, CSS and JavaScript that power the user interface, "this minimal UI's HTML and dependent assets", typically the header, navigation and other elements that persist across pages, and it lists two benefits: reliable, consistent performance on repeat visits, and reliable access to functionality offline, even for URLs the user has never visited. The second point is easy to miss and is the real reason the model exists: because every URL maps to the same cached document, a user can open any deep link while offline and still get a working app, instead of the browser's offline error page.

flowchart LR
    subgraph Shell["App shell: precached, versioned with the app"]
        H["shell.html: header, nav, layout, skeleton"]
        C["Critical CSS: inlined"]
        J["App JS: router, view code"]
        F["Fonts, icons"]
    end
    subgraph Content["Content: per route, fetched at runtime"]
        A["API responses: JSON"]
        I["Images, media"]
        D["IndexedDB: offline data"]
    end
    SW["Service worker"] -->|"every navigation"| H
    J -->|"fetch()"| A
    J -->|"read"| D
    J -->|"render into main"| I

Anatomy of a shell

Part In the shell? Why
Document skeleton: <head>, app bar, navigation, footer, empty <main> Yes Identical on every route; paints the frame of the app immediately
Critical CSS for the frame and skeleton Yes, inlined in shell.html Avoids a render-blocking request, even a cached one, before first paint
Router and view code for the default route Yes Needed to render anything
View code for rarely used routes No, lazy-load and runtime-cache it Every precached byte is downloaded at install and parsed at launch
Web fonts and UI icons Usually, if used on every screen Avoids font swaps and missing icons offline
Web app manifest and app icons Yes Needed for installability and the splash screen
Page-specific content (text, product data, messages) Never It would be stale and user-specific; fetch it at runtime and cache it separately
User data, auth tokens Never in the shell HTML The shell is shared by every user of that browser profile and served offline

Shell versus content: the rule of thumb

If a piece of the page would be identical for every user and every URL until your next deployment, it belongs in the shell. If it changes with the URL, the user, or time, it is content. A shell that contains content (a pre-rendered home feed, the user's name) either goes stale or has to be re-cached constantly, which defeats the model.

How an app-shell load works

The model behaves very differently on the first visit, on repeat visits, and offline. Understanding all three matters because your metrics mix them.

First visit: no service worker yet

On the very first visit there is no worker, so the server must answer the navigation itself. It can return either the same shell (the usual SPA fallback: every unknown path returns index.html) or a fully server-rendered page. Once the page has loaded, it registers the service worker, whose install event precaches the shell and its assets.

sequenceDiagram
    participant B as Browser
    participant S as Server
    participant W as Service worker
    B->>S: GET /notes/42
    S-->>B: shell.html (SPA fallback) or SSR page
    B->>S: GET app.js, app.css
    B->>S: GET /api/notes/42
    Note over B: Render content: LCP
    B->>W: register("/sw.js") after load
    W->>S: install: precache shell.html, app.js, app.css, fonts
    Note over W: activate: this and later navigations are controlled

Repeat visit: shell from the cache

On later visits, the worker intercepts the navigation and responds with the cached shell.html, whatever the URL. The browser parses it, paints the frame and skeleton (FCP), loads the precached JavaScript (a cache read), and the router then fetches the content for the URL.

sequenceDiagram
    participant B as Browser
    participant W as Service worker
    participant C as Cache Storage
    participant S as Server
    B->>W: navigate /notes/42 (start worker if stopped)
    W->>C: match("/shell.html")
    C-->>W: shell
    W-->>B: respondWith(shell): TTFB
    Note over B: Parse, paint app bar and skeleton: FCP
    B->>W: GET /assets/app.8d41e0c7.js
    W->>C: match
    C-->>W: app.js
    W-->>B: app.js
    Note over B: Execute router
    B->>W: GET /api/notes/42
    W->>S: network first
    S-->>W: JSON
    W-->>B: JSON
    Note over B: Render note, load hero image: LCP

Offline visit

Offline, the navigation still gets the shell from the cache. The content request fails or is answered from a runtime cache or from IndexedDB, and the router renders either the cached content or an offline state inside the shell, with the navigation, settings and other local features still working. See Offline UX & Fallbacks for how to design that state.

Which metric each phase affects

Phase (repeat visit) Typical cost driver Metric it lands in
Worker start-up (if stopped) Device CPU, worker script size and top-level code TTFB
Cache read of shell.html Small; grows with shell size TTFB
Parse shell, apply inlined CSS, first paint Shell HTML and CSS size FCP
Fetch and execute app JavaScript Bundle size and parse/compile time, not network Resource load delay (LCP), input delay (INP)
Content request Network round trip and API latency Resource load delay (LCP)
Client rendering of the view Framework work, DOM size Element render delay (LCP), CLS if the skeleton does not match
LCP image download Image size, cache hit or miss Resource load duration (LCP)

When the app shell model fits and when it does not

The app shell is an architecture for applications, not for documents. The more your product looks like an email client and the less it looks like a newspaper, the better it fits.

Good fit Poor fit
Client-rendered SPA with client-side routing Multi-page apps where each page is a separate server-rendered document
Logged-in apps: mail, chat, project tools, dashboards, editors Content sites: articles, documentation, blogs, marketing pages
Users return frequently, often from the home screen Most visits are first visits from search or social links
Must work offline for any URL, including deep links Offline support only needs a fallback page and a few cached articles
Persistent UI (app bar, navigation rail, player) survives route changes Each page has a different layout
Content comes from an API you already have Content is HTML generated by a CMS
SEO does not matter for the app routes (they are behind login or noindex) Pages must rank and must render meaningful HTML without JavaScript
flowchart TD
    A{"Is most of the UI identical across routes?"} -- No --> X["Serve rendered pages: network-first or SWR HTML"]
    A -- Yes --> B{"Are most loads first visits from search or links?"}
    B -- Yes --> Y["SSR for first visits, consider a streaming shell"]
    B -- No --> C{"Is the content already client-rendered from an API?"}
    C -- No --> Y
    C -- Yes --> D{"Must any URL open offline?"}
    D -- Yes --> Z["App shell"]
    D -- No --> E{"Is FCP on repeat visits the priority?"}
    E -- Yes --> Z
    E -- No --> Y

For multi-page apps, the equivalent of an app shell is caching rendered pages and serving them network-first with navigation preload, or stale-while-revalidate for content that tolerates staleness, plus an offline fallback page. SPA vs MPA PWAs compares the two architectures in depth.

Framework defaults can turn an MPA into an app shell by accident

Several tools enable the app shell pattern by default. vite-plugin-pwa in generateSW mode sets Workbox's navigateFallback to index.html, so every navigation the precache does not match is answered with index.html. If your site is server-rendered or multi-page, that default serves the wrong document for every page you did not precache. Set navigateFallback: null or an explicit allow list for such sites.

App shell versus server rendering: the LCP trade-off

Where the time goes

Break LCP into its four subparts (see Core Web Vitals) and compare the architectures on a repeat visit with a cold service worker, the most common case for a PWA launched from the home screen:

Subpart Server-rendered page, network-first with preload Client-rendered app shell Streaming shell with server fragment
TTFB Network round trip + server render time Worker start-up + cache read: fast Worker start-up + cache read: fast
Resource load delay Small: the LCP image is in the HTML Large: JS execute + API round trip + render before the image is discovered Small to medium: the fragment arrives with the network, and the image is in it
Resource load duration Image download (cache hit if runtime-cached) Same Same
Element render delay Small Medium: framework render, hydration of the view Small
First Contentful Paint After TTFB + CSS Immediately after TTFB Immediately after TTFB

The app shell wins TTFB and FCP by a wide margin and then gives much of it back. The API request for the content cannot start until the shell's JavaScript has run, so the content's network round trip is serialized after worker start-up, cache reads, parsing and script execution, instead of overlapping them as the navigation request does for a server-rendered page. Unless the content is itself cached (runtime cache, IndexedDB), the app-shell LCP is roughly "SSR LCP + JavaScript boot time", minus the server's render time.

Content from cache changes the equation

The model shines when the content is also local. If the router renders the note from IndexedDB first and then revalidates from the network, the repeat-visit LCP is worker start-up + shell + JavaScript + a local read + render, with no network on the critical path at all. That is the offline-first architecture described in Offline-First Data & Sync, and it is the case in which an app shell beats any server-rendered architecture on every metric. An app shell with network-only content is the case in which it usually loses on LCP.

First visits decide the 75th percentile

Core Web Vitals are assessed at the 75th percentile of all page loads. The first visit of every new user has no service worker, and a client-rendered shell on a first visit is the slowest architecture of all: HTML, then JavaScript over the network, then the API call, then render. If new visitors are a significant share of your traffic, their LCP decides your score. The common mitigation is hybrid rendering: the server renders full HTML for every URL (fast first visit, meaningful HTML for crawlers), and the service worker serves the shell for later navigations. web.dev's Rendering on the Web calls the combination of streaming server rendering for initial loads with service-worker rendering for subsequent navigations trisomorphic rendering. The cost is that your views must render on the server, in the client and ideally in the worker, which usually means a framework that supports all three.

INP and CLS in an app shell

  • INP. A cached shell paints fast, so users start tapping sooner, while the application JavaScript is still executing or hydrating. Those early interactions pay a long input delay. Keep the shell's boot path small, lazy-load non-critical views, and yield during start-up. The web-vitals attribution field loadState tells you whether poor interactions happen during load.
  • CLS. The shell-to-content swap is a layout shift unless the skeleton reserves exactly the space the content will take. A skeleton list of four 72 px rows replaced by twenty 96 px rows shifts everything below the first row. So does an app bar whose height changes when the user's avatar loads.

Summary: choosing an approach

Priority Best approach
Instant launch from the home screen, offline for any URL App shell (plus content in IndexedDB for the best LCP)
Fast first visits and SEO Server rendering or static HTML, with network-first or SWR caching in the worker
Both Server rendering for first visits, app shell or streaming shell for later navigations
Lowest engineering cost for a content site No shell: cache rendered pages and provide an offline page

Designing the shell

What belongs in the shell

  • The document <head>: charset, viewport (with viewport-fit=cover if you use safe-area insets in standalone mode), manifest link, theme color, and a generic <title> that the router replaces.
  • The persistent chrome: app bar, navigation, and any area that is shared by all views.
  • A <main> container with an accessible loading state: aria-busy="true" and a skeleton marked aria-hidden="true".
  • Inlined critical CSS for all of the above and the skeleton.
  • A module script for the router and the default view. Module scripts are deferred, so they never block parsing.
  • A <noscript> message. A shell is useless without JavaScript; say so instead of showing an eternal skeleton.

Keep the shell small

Every byte in the precache is downloaded during the first visit's install event, and every byte of shell HTML, CSS and JavaScript is read, parsed and executed on every launch. Cache reads are fast; parsing and compiling JavaScript on a mid-range phone is not. Set a budget for the shell's compressed transfer size and for the JavaScript executed before the first content render, and enforce it in CI (see Measuring Performance).

Make the skeleton match the content

A skeleton exists to reduce perceived waiting and to reserve space. It only achieves the second if its geometry matches the content: same row heights, same image aspect ratios, same number of above-the-fold items when that number is known. When it is not known, prefer a neutral full-height container (min-block-size) over a skeleton that the real content will shift.

The shell document

src/shell.html
<!doctype html>
<html lang="en" data-shell data-build="%BUILD_ID%">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <!-- Generic title; the router sets the real one after rendering a view. -->
  <title>Acme Notes</title>
  <meta name="theme-color" content="#1a56db">
  <link rel="manifest" href="/manifest.webmanifest">
  <link rel="icon" href="/icons/icon-192.png" sizes="192x192">
  <style>
    /* Critical CSS for the frame and the skeleton, inlined so that the shell
       paints without waiting for any other response, even a cached one. */
    :root { color-scheme: light dark; --bar: 56px; --accent: #1a56db; }
    *, *::before, *::after { box-sizing: border-box; }
    body { margin: 0; font: 16px/1.5 system-ui, sans-serif; }
    .app-bar {
      position: sticky; top: 0; z-index: 1;
      display: flex; align-items: center; gap: 1rem;
      /* Height is fixed so nothing below it can shift when content arrives. */
      block-size: calc(var(--bar) + env(safe-area-inset-top));
      padding: env(safe-area-inset-top) 1rem 0;
      background: var(--accent); color: #fff;
    }
    .app-bar a { color: inherit; text-decoration: none; }
    .app-bar nav { display: flex; gap: 1rem; margin-inline-start: auto; }
    main { max-inline-size: 60rem; margin: 0 auto; padding: 1rem; min-block-size: calc(100vh - var(--bar)); }
    .skeleton { display: grid; gap: 12px; }
    .skeleton > div { block-size: 72px; border-radius: 8px; background: rgb(127 127 127 / 0.15); }
    @media (prefers-reduced-motion: no-preference) {
      .skeleton > div { animation: pulse 1.5s ease-in-out infinite; }
      @keyframes pulse { 50% { opacity: 0.5; } } /* opacity animates on the compositor */
    }
  </style>
  <!-- View styles: render-blocking, but served from the precache. -->
  <link rel="stylesheet" href="/assets/app.3f9c2a1b.css">
  <script type="module" src="/assets/app.8d41e0c7.js"></script>
</head>
<body>
  <header class="app-bar">
    <a href="/" class="brand">Acme Notes</a>
    <nav aria-label="Primary">
      <a href="/">Notes</a>
      <a href="/settings">Settings</a>
    </nav>
  </header>
  <main id="view" tabindex="-1" aria-busy="true">
    <div class="skeleton" aria-hidden="true">
      <div></div><div></div><div></div><div></div><div></div>
    </div>
  </main>
  <noscript>
    <p>Acme Notes needs JavaScript. Enable it, or use the <a href="/basic/">basic HTML version</a>.</p>
  </noscript>
</body>
</html>

The data-shell attribute marks documents that are the shell, so your RUM code can tell shell-served loads from server-rendered ones (see Measuring an app shell). %BUILD_ID% is replaced by the build script below; it identifies the release that produced this shell.

Implementing an app shell with a vanilla service worker

The implementation below is complete and framework-free: a build script that produces a revisioned precache manifest, a service worker that precaches incrementally and routes navigations to the shell, the client-side router that renders content into the shell, and the registration code. The file layout it assumes:

Project layout
src/
  shell.html            # the document above
  sw.js                 # service worker source with injection points
  app.js                # router and views (bundled to dist/assets/app.<hash>.js)
scripts/
  build-sw.mjs          # runs after the bundler
dist/                   # bundler output, deployed as-is
  shell.html
  sw.js
  assets/app.8d41e0c7.js, assets/app.3f9c2a1b.css, ...
  manifest.webmanifest, icons/...

Build step: a revisioned precache manifest

The service worker needs to know exactly which files make up this release and whether each one changed since the last release. Fingerprinted files (app.8d41e0c7.js) carry their version in the URL; everything else needs a content hash as its revision. The script also derives a build ID from all precached content and injects it into sw.js, which guarantees the worker script is byte-different whenever any precached file changes, and that is what triggers a service worker update.

scripts/build-sw.mjs
// Generates dist/sw.js from src/sw.js with a precache manifest.
// Requires Node.js 20 or later (readdir with { recursive: true }).
import { createHash } from "node:crypto";
import { readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";

const DIST = path.resolve("dist");
const SW_SOURCE = path.resolve("src/sw.js");

// What goes into the precache: the shell and everything it needs offline.
const INCLUDE = /\.(?:html|js|css|woff2|svg|png|webmanifest)$/;
const EXCLUDE = [
  /^sw\.js$/, // the worker never precaches itself
  /\.map$/, // source maps are for DevTools, not users
  /^img\/content\//, // content images are runtime-cached, not precached
  /^basic\//, // the no-JavaScript fallback is server-rendered
];
// Files whose names contain a content hash, like app.8d41e0c7.js.
const FINGERPRINTED = /\.[0-9a-f]{8,}\.[a-z0-9]+$/;

const sha = (data) => createHash("sha256").update(data).digest("hex");

async function listFiles(dir) {
  const entries = await readdir(dir, { recursive: true, withFileTypes: true });
  return entries
    .filter((entry) => entry.isFile())
    .map((entry) => path.join(entry.parentPath ?? entry.path, entry.name))
    .map((file) => path.relative(dir, file).split(path.sep).join("/"))
    .filter((file) => INCLUDE.test(file) && !EXCLUDE.some((re) => re.test(file)))
    .sort(); // stable order, so the same input always yields the same output
}

const files = await listFiles(DIST);
if (!files.includes("shell.html")) {
  throw new Error("dist/shell.html is missing: the app shell must be precached");
}

// 1. Build ID over every precached file, so any change produces a new worker.
const buildHash = createHash("sha256");
for (const file of files) {
  buildHash.update(file).update(await readFile(path.join(DIST, file)));
}
const BUILD_ID = buildHash.digest("hex").slice(0, 12);

// 2. Stamp the build ID into the shell before computing its revision.
const shellPath = path.join(DIST, "shell.html");
const shellSource = await readFile(shellPath, "utf8");
await writeFile(shellPath, shellSource.replaceAll("%BUILD_ID%", BUILD_ID));

// 3. Manifest entries: fingerprinted files need no revision (null).
const manifest = [];
for (const file of files) {
  const url = `/${file}`;
  const revision = FINGERPRINTED.test(file)
    ? null
    : sha(await readFile(path.join(DIST, file))).slice(0, 16);
  manifest.push({ url, revision });
}

// 4. Inject into the worker source.
const swSource = await readFile(SW_SOURCE, "utf8");
for (const marker of ["self.__SHELL_MANIFEST", "%BUILD_ID%"]) {
  if (!swSource.includes(marker)) throw new Error(`src/sw.js lacks the ${marker} injection point`);
}
const swOutput = swSource
  .replace("self.__SHELL_MANIFEST", JSON.stringify(manifest))
  .replaceAll("%BUILD_ID%", BUILD_ID);
await writeFile(path.join(DIST, "sw.js"), swOutput);

console.log(`sw.js: ${manifest.length} precached files, build ${BUILD_ID}`);

The service worker

src/sw.js
/* App shell service worker. Processed by scripts/build-sw.mjs. */
const BUILD_ID = "%BUILD_ID%";
const MANIFEST = self.__SHELL_MANIFEST; // [{ url, revision }]
const SHELL_URL = "/shell.html";

const PRECACHE = `precache-${BUILD_ID}`;
const API_CACHE = "api-v1";
const IMAGE_CACHE = "images-v1";
const IMAGE_CACHE_MAX_ENTRIES = 200;
const API_TIMEOUT_MS = 3000;
const REVISION_HEADER = "X-Precache-Revision";

const PRECACHED_PATHS = new Set(MANIFEST.map((entry) => entry.url));

// Navigations that must reach the server instead of getting the shell.
// Matched against pathname + search, like Workbox's NavigationRoute.
const SHELL_DENYLIST = [
  /^\/api\//, // JSON opened directly in a tab
  /^\/auth\//, // OAuth redirects and login forms are server-rendered
  /^\/basic\//, // the no-JavaScript version of the app
  /^\/admin(?:\/|$)/, // a separate, server-rendered application
  /\/[^/?]+\.[a-z0-9]+(?:\?|$)/i, // URLs that look like files: /export.csv, /feed.xml
];

// ---------------------------------------------------------------- install

self.addEventListener("install", (event) => {
  event.waitUntil(precache());

  // Chrome 123+ and Safari 27: serve fingerprinted assets straight from the
  // precache without starting this worker. Browsers without static routing
  // fall through to the fetch handler below, which does the same thing.
  if (typeof event.addRoutes === "function") {
    event
      .addRoutes([{ condition: { urlPattern: "/assets/*" }, source: { cacheName: PRECACHE } }])
      .catch((error) => console.warn("Static routes rejected", error));
  }
});

async function precache() {
  const cache = await caches.open(PRECACHE);
  const previous = await Promise.all(
    (await caches.keys())
      .filter((name) => name.startsWith("precache-") && name !== PRECACHE)
      .map((name) => caches.open(name)),
  );

  try {
    await Promise.all(MANIFEST.map((entry) => precacheEntry(cache, previous, entry)));
  } catch (error) {
    // Never leave a half-filled cache behind: a failed install is retried on
    // the next update check, and the old worker keeps serving meanwhile.
    await caches.delete(PRECACHE);
    throw error;
  }
}

async function precacheEntry(cache, previousCaches, { url, revision }) {
  const wanted = String(revision);

  // Reuse an identical copy from the previous release instead of downloading.
  for (const old of previousCaches) {
    const hit = await old.match(url);
    if (hit && hit.headers.get(REVISION_HEADER) === wanted) {
      await cache.put(url, hit);
      return;
    }
  }

  // Unversioned files bypass the HTTP cache ("reload") so a stale copy can
  // never be precached; fingerprinted files are immutable and may use it.
  const response = await fetch(url, { cache: revision === null ? "default" : "reload" });
  if (!response.ok) {
    throw new Error(`Precache request for ${url} failed with ${response.status}`);
  }

  // Re-wrapping the response adds the revision header and drops the
  // "redirected" flag: a redirected response cannot answer a navigation.
  const headers = new Headers(response.headers);
  headers.set(REVISION_HEADER, wanted);
  await cache.put(
    url,
    new Response(response.body, {
      status: response.status,
      statusText: response.statusText,
      headers,
    }),
  );
}

// --------------------------------------------------------------- activate

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      // Navigations are answered from the cache, so a navigation preload
      // request would be wasted. The setting persists on the registration,
      // so switch it off explicitly in case an earlier version enabled it.
      if (self.registration.navigationPreload) {
        await self.registration.navigationPreload.disable();
      }
      const names = await caches.keys();
      await Promise.all(
        names
          .filter((name) => name.startsWith("precache-") && name !== PRECACHE)
          .map((name) => caches.delete(name)),
      );
    })(),
  );
});

// ------------------------------------------------------------------ fetch

self.addEventListener("fetch", (event) => {
  const { request } = event;
  if (request.method !== "GET") return; // mutations always go to the network

  const url = new URL(request.url);

  if (request.mode === "navigate") {
    const sameOrigin = url.origin === self.location.origin;
    const denied = SHELL_DENYLIST.some((re) => re.test(url.pathname + url.search));
    if (sameOrigin && !denied) {
      event.respondWith(serveShell(request));
    }
    return; // denied navigations get the browser's default network handling
  }

  if (url.origin !== self.location.origin) return; // third parties: not our business

  if (PRECACHED_PATHS.has(url.pathname)) {
    event.respondWith(fromPrecache(request, url.pathname));
  } else if (url.pathname.startsWith("/api/")) {
    event.respondWith(networkFirst(event, API_CACHE, API_TIMEOUT_MS));
  } else if (request.destination === "image") {
    event.respondWith(cacheFirst(event, IMAGE_CACHE, IMAGE_CACHE_MAX_ENTRIES));
  }
  // Everything else falls through to the network.
});

async function serveShell(request) {
  const cache = await caches.open(PRECACHE);
  const shell = await cache.match(SHELL_URL);
  if (shell) return shell;

  // The shell should always be there; if it is not (caches cleared by hand,
  // a cleanup bug), the server's own SPA fallback is the next best thing.
  try {
    return await fetch(request);
  } catch {
    return new Response(
      "<!doctype html><meta charset=utf-8><title>Offline</title><h1>You are offline</h1>",
      { status: 503, headers: { "Content-Type": "text/html; charset=utf-8" } },
    );
  }
}

async function fromPrecache(request, pathname) {
  const cache = await caches.open(PRECACHE);
  // Query strings are not part of precache keys (for example ?utm_source).
  return (await cache.match(pathname)) ?? fetch(request);
}

async function networkFirst(event, cacheName, timeoutMs) {
  const cache = await caches.open(cacheName);
  const network = fetch(event.request);
  // Write successful responses to the cache without delaying the page: the
  // clone is taken in the first reaction to the response, before the page can
  // read the body. API data is per user: clear this cache on logout (see the
  // "LOGOUT" message below). A failed write (quota) must not fail the request.
  const update = network
    .then((response) => (response.ok ? cache.put(event.request, response.clone()) : undefined))
    .catch(() => undefined);
  // Keep the worker alive until the cache write settles, even when the cached
  // copy wins the race below, so the cache is refreshed for next time.
  event.waitUntil(update);

  let timer;
  const timeout = new Promise((resolve) => {
    timer = setTimeout(resolve, timeoutMs, null);
  });
  try {
    const winner = await Promise.race([network, timeout]);
    if (winner) return winner;
    // Slow network: answer from the cache if possible, otherwise keep waiting.
    return (await cache.match(event.request)) ?? (await network);
  } catch (error) {
    const cached = await cache.match(event.request);
    if (cached) return cached;
    throw error; // the page's fetch() rejects, and the router shows its offline state
  } finally {
    clearTimeout(timer);
  }
}

async function cacheFirst(event, cacheName, maxEntries) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(event.request);
  if (cached) return cached;

  const response = await fetch(event.request);
  if (response.ok) {
    event.waitUntil(
      cache.put(event.request, response.clone()).then(() => trimCache(cache, maxEntries)),
    );
  }
  return response;
}

async function trimCache(cache, maxEntries) {
  const keys = await cache.keys(); // insertion order: oldest first
  const excess = keys.length - maxEntries;
  if (excess > 0) {
    await Promise.all(keys.slice(0, excess).map((key) => cache.delete(key)));
  }
}

// --------------------------------------------------------------- messages

self.addEventListener("message", (event) => {
  switch (event.data?.type) {
    case "SKIP_WAITING": // sent by the page when the user accepts an update
      self.skipWaiting();
      break;
    case "LOGOUT": // per-user data must not survive a logout
      event.waitUntil(caches.delete(API_CACHE));
      break;
    case "GET_BUILD_ID":
      event.ports[0]?.postMessage(BUILD_ID);
      break;
  }
});

A few decisions in this worker are worth spelling out:

  • The same response for every URL. serveShell() ignores the request URL entirely; the router in the page reads location.pathname to decide what to render. That is what makes deep links work offline.
  • The deny list is not optional. Without it, a user following an emailed link to /export.csv, or the identity provider redirecting to /auth/callback?code=…, would receive the shell, and your router would render "not found". Deny-listed navigations are not answered at all (no respondWith()), so they behave exactly as without a worker.
  • Navigation preload is disabled. The worker never uses the network for shell navigations, so a preload request would only add server load. It is disabled explicitly because the setting lives on the registration and survives worker updates. The Navigation Preload page explains why app shells are the exception to "always enable it".
  • Incremental precaching. Copying unchanged files from the previous release's cache means an update downloads only what changed. Workbox's precaching does the same thing with its own revision bookkeeping.
  • No clients.claim(). On the first install, the page that registered the worker stays uncontrolled until its next navigation. That is harmless for an app shell (the page already has everything it needs) and avoids evicting other tabs from the back/forward cache, which claim() does in Chrome. See Lifecycle.

Choosing which navigations get the shell

A navigation should get the shell only if the client-side router can render that URL. Build the deny list from these categories:

URL category Example Why it must reach the server
API endpoints /api/notes/42 opened in a tab The user expects JSON, not the app
Authentication /auth/callback?code=…, /login, /logout The server sets cookies and redirects
Server-rendered sections /admin, /blog, /docs Different application or content site
Files /exports/report.pdf, /sitemap.xml, /robots.txt The response is a file, not a page
Well-known URLs /.well-known/assetlinks.json Read by other software
Server-side redirects Old URLs you redirect with 301 The shell would render "not found" instead of redirecting

The alternative is an allow list of the router's own route patterns. Allow lists are safer when the origin hosts many things besides the app; deny lists are simpler when the app owns the whole origin. Workbox supports both, and when both are configured the deny list wins.

The first visit to any deep link, and every visit in a browser without service worker support, is answered by the server. The server must therefore return the shell (or a server-rendered page) for every route the client router knows, with status 200, while still returning real 404s for unknown URLs if you care about crawlers. The typical SPA fallback in nginx:

nginx.conf (excerpt)
location / {
    # Real files first; everything else is an app route served by the shell.
    try_files $uri /shell.html;
}

location = /shell.html {
    # The shell must be revalidated: its content changes every release.
    add_header Cache-Control "no-cache";
}

location /assets/ {
    # Fingerprinted: safe to cache forever.
    add_header Cache-Control "public, max-age=31536000, immutable";
}

location = /sw.js {
    # With the default updateViaCache: "imports", update checks bypass the
    # HTTP cache for this file anyway; no-cache also covers the first
    # registration and intermediaries such as CDNs.
    add_header Cache-Control "no-cache";
}

The HTTP caching headers matter as much as the worker: precacheEntry() uses cache: "reload" for unversioned files, but anything the browser fetches before the worker is installed follows these headers. HTTP Caching & Service Workers covers the interplay.

The client-side router

The router renders content for location.pathname into the shell's <main>, intercepts same-origin link clicks, and handles Back and Forward. It gives the LCP image high priority on the initial render, sets the document title, moves focus for screen reader users, and distinguishes offline from other failures.

src/app.js
// Router and views for the app shell. Bundled to /assets/app.<hash>.js.
const view = document.getElementById("view");
let inflight = null; // AbortController of the route currently loading
let firstRender = true;

const routes = [
  {
    pattern: /^\/$/,
    load: (params, signal) => fetchJSON("/api/notes?limit=20", signal),
    render: renderNoteList,
  },
  {
    pattern: /^\/notes\/([\w-]+)$/,
    load: ([id], signal) => fetchJSON(`/api/notes/${encodeURIComponent(id)}`, signal),
    render: renderNote,
  },
  {
    pattern: /^\/settings$/,
    load: async () => null, // purely local view: nothing to fetch
    render: renderSettings,
  },
];

class HttpError extends Error {
  constructor(status) {
    super(`HTTP ${status}`);
    this.status = status;
  }
}

async function fetchJSON(url, signal) {
  const response = await fetch(url, { signal, headers: { Accept: "application/json" } });
  if (!response.ok) throw new HttpError(response.status);
  return response.json();
}

const escapeHTML = (value) =>
  String(value).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);

// ------------------------------------------------------------------ views

function renderNoteList(notes) {
  return {
    title: "Notes",
    html: `<h1>Notes</h1>
      <ul class="note-list">
        ${notes
          .map(
            (n) => `<li class="list-row"><a href="/notes/${encodeURIComponent(n.id)}">
              ${escapeHTML(n.title)}</a></li>`,
          )
          .join("")}
      </ul>`,
  };
}

function renderNote(note) {
  // On the initial render the hero image is the likely LCP element: give it
  // high priority. Later (soft) navigations keep the default priority.
  const priority = firstRender ? ' fetchpriority="high"' : "";
  // Number() keeps API-supplied dimensions from injecting markup; the explicit
  // width and height reserve the image's box and prevent a layout shift.
  const w = Number(note.image?.width);
  const h = Number(note.image?.height);
  const size = w > 0 && h > 0 ? ` width="${w}" height="${h}"` : "";
  const hero = note.image
    ? `<img src="${escapeHTML(note.image.src)}"${size}
         alt="${escapeHTML(note.image.alt)}"${priority}>`
    : "";
  return {
    title: note.title,
    html: `<article><h1>${escapeHTML(note.title)}</h1>${hero}
      <div class="note-body">${escapeHTML(note.text)}</div></article>`,
  };
}

function renderSettings() {
  return { title: "Settings", html: `<h1>Settings</h1><p>Settings are stored on this device.</p>` };
}

function renderProblem(error) {
  if (error instanceof HttpError && error.status === 404) {
    return { title: "Not found", html: `<h1>Not found</h1><p><a href="/">Back to your notes</a></p>` };
  }
  if (!navigator.onLine || error instanceof TypeError) {
    // fetch() rejects with a TypeError on network failure (and the service
    // worker found nothing in its cache either).
    return {
      title: "Offline",
      html: `<h1>You are offline</h1><p>This note has not been saved for offline use yet.</p>
        <button type="button" data-action="retry">Try again</button>`,
    };
  }
  return { title: "Something went wrong", html: `<h1>Something went wrong</h1>
    <button type="button" data-action="retry">Try again</button>` };
}

// ----------------------------------------------------------------- router

async function render(pathname) {
  inflight?.abort(); // a newer navigation supersedes the pending one
  const controller = new AbortController();
  inflight = controller;

  const match = routes
    .map((route) => ({ route, params: pathname.match(route.pattern) }))
    .find(({ params }) => params);

  view.setAttribute("aria-busy", "true");
  let result;
  try {
    if (!match) throw new HttpError(404);
    const data = await match.route.load(match.params.slice(1), controller.signal);
    result = match.route.render(data);
  } catch (error) {
    if (error.name === "AbortError") return; // superseded: render nothing
    console.error("Route failed", pathname, error);
    result = renderProblem(error);
  }

  view.innerHTML = result.html;
  view.setAttribute("aria-busy", "false");
  document.title = `${result.title} · Acme Notes`;
  performance.mark("content-rendered", { detail: { route: match?.route.pattern.source ?? "404" } });

  if (!firstRender) {
    // Soft navigation: move focus to the new content for assistive technology.
    view.focus({ preventScroll: true });
    window.scrollTo(0, 0);
  }
  firstRender = false;
}

function navigate(url, { replace = false } = {}) {
  if (replace) history.replaceState(null, "", url);
  else history.pushState(null, "", url);
  render(location.pathname);
}

document.addEventListener("click", (event) => {
  const retry = event.target.closest("[data-action=retry]");
  if (retry) {
    render(location.pathname);
    return;
  }
  const link = event.target.closest("a[href]");
  if (
    !link ||
    event.defaultPrevented ||
    event.button !== 0 ||
    event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || // new tab/window
    link.target || link.hasAttribute("download") ||
    link.origin !== location.origin
  ) {
    return; // let the browser handle it
  }
  const url = new URL(link.href);
  if (url.pathname === location.pathname && url.search === location.search) {
    event.preventDefault();
    return;
  }
  // Paths the worker deny-lists are server territory: full navigation. Keep
  // this in sync with SHELL_DENYLIST in sw.js (sections and file-like URLs).
  if (
    /^\/(?:api|auth|basic|admin)(?:\/|$)/.test(url.pathname) ||
    /\/[^/]+\.[a-z0-9]+$/i.test(url.pathname)
  ) {
    return;
  }
  event.preventDefault();
  navigate(url.href);
});

addEventListener("popstate", () => render(location.pathname));

render(location.pathname);

// ------------------------------------------------------ worker registration

if ("serviceWorker" in navigator) {
  // Register after load so a first-time visitor's precaching does not compete
  // with the page's own requests for bandwidth.
  addEventListener("load", () => {
    navigator.serviceWorker
      .register("/sw.js", { scope: "/" })
      .catch((error) => console.error("Service worker registration failed", error));
  });
}

This router is intentionally minimal. A production app would use a framework router, the Navigation API (Chrome 102, Firefox 147, Safari 26.2) instead of click interception, and View Transitions for route changes, but the responsibilities are the same: render from the URL, keep the shell, handle failure explicitly, and keep route changes fast, because every route change is an interaction measured by INP. The update prompt that sends SKIP_WAITING is covered in Updating Service Workers.

Implementing an app shell with Workbox

Workbox packages the same pattern: precacheAndRoute() for the revisioned precache, and a NavigationRoute whose handler always answers with the precached shell. NavigationRoute only matches requests whose mode is navigate; its allowlist and denylist regular expressions are matched against the URL's pathname plus search, and the deny list takes precedence when both are given. createHandlerBoundToURL() returns a handler that responds with a specific precached URL and throws if that URL is not in the precache manifest, which catches configuration mistakes early.

src/sw.js
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from "workbox-precaching";
import { NavigationRoute, registerRoute } from "workbox-routing";
import { CacheFirst, NetworkFirst } from "workbox-strategies";
import { ExpirationPlugin } from "workbox-expiration";

// Replaced at build time with [{ url, revision }, ...].
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();

// Every in-scope navigation gets the precached shell, except these.
registerRoute(
  new NavigationRoute(createHandlerBoundToURL("/shell.html"), {
    denylist: [/^\/api\//, /^\/auth\//, /^\/basic\//, /^\/admin(?:\/|$)/, /\/[^/?]+\.[a-z0-9]+(?:\?|$)/i],
  }),
);

registerRoute(
  ({ url, request }) => url.origin === self.location.origin && url.pathname.startsWith("/api/") && request.method === "GET",
  new NetworkFirst({ cacheName: "api-v1", networkTimeoutSeconds: 3 }),
);

registerRoute(
  ({ request }) => request.destination === "image",
  new CacheFirst({
    cacheName: "images-v1",
    plugins: [new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60 })],
  }),
);

self.addEventListener("message", (event) => {
  if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
});
workbox-config.cjs
module.exports = {
  globDirectory: "dist/",
  globPatterns: ["**/*.{html,js,css,woff2,svg,png,webmanifest}"],
  globIgnores: ["img/content/**", "basic/**"],
  swDest: "dist/sw.js",
  // The shell URL must be part of the precache.
  navigateFallback: "/shell.html",
  // Keep these simple: they may run for every navigation.
  navigateFallbackDenylist: [/^\/api\//, /^\/auth\//, /^\/basic\//, /^\/admin(?:\/|$)/],
  runtimeCaching: [
    {
      urlPattern: ({ url }) => url.pathname.startsWith("/api/"),
      handler: "NetworkFirst",
      options: { cacheName: "api-v1", networkTimeoutSeconds: 3 },
    },
    {
      urlPattern: ({ request }) => request.destination === "image",
      handler: "CacheFirst",
      options: { cacheName: "images-v1", expiration: { maxEntries: 200 } },
    },
  ],
};
vite.config.js
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";

export default defineConfig({
  plugins: [
    VitePWA({
      registerType: "prompt", // show an update prompt instead of reloading silently
      workbox: {
        // generateSW mode already defaults navigateFallback to "index.html";
        // spell it out, and deny-list what must reach the server.
        navigateFallback: "index.html",
        navigateFallbackDenylist: [/^\/api\//, /^\/auth\//],
        globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
      },
    }),
  ],
});

Workbox's generateSW defaults navigateFallback to null (no shell), and its build types warn that navigateFallbackAllowlist / navigateFallbackDenylist regular expressions may be evaluated against every navigation URL, so complex expressions can delay navigations. Workbox Fundamentals and Vite PWA Plugin cover configuration in depth.

Skipping worker start-up for shell assets with static routing

In an app-shell PWA, the worker is woken for the navigation anyway (it has to pick the shell), but it does not need to be involved in the dozen requests for fingerprinted scripts, styles and fonts that follow. The Static Routing API (Chrome 123+, Safari 27) lets the browser answer those from Cache Storage directly:

sw.js (install excerpt)
self.addEventListener("install", (event) => {
  event.waitUntil(precache());
  if (typeof event.addRoutes === "function") {
    event
      .addRoutes([
        // Exact-URL cache lookups in this release's precache; a miss goes to
        // the network, never to the fetch handler.
        { condition: { urlPattern: "/assets/*" }, source: { cacheName: PRECACHE } },
        // Uncached content images: no reason to wake the worker.
        { condition: { urlPattern: "/img/content/*" }, source: "network" },
      ])
      .catch((error) => console.warn("Static routes rejected", error));
  }
});

Static routes cannot serve the shell for navigations: a "cache" source looks up the request's own URL, so /notes/42 would never match /shell.html. Navigations still need the fetch handler (or the whole shell pattern needs rethinking). Rules are stored with the worker version, which is why the example uses the version-specific cache name: each release routes to its own precache.

Streaming app shells

Why stream

A classic shell makes the content request wait until the shell's JavaScript runs. A streaming shell removes that serialization. The worker responds immediately with a stream whose first chunk is the cached shell head (so FCP stays instant), then pipes in a server-rendered content fragment fetched from the network, then the cached shell footer. The browser parses and renders the stream progressively, so the LCP element arrives as HTML in the first response, and the fragment request starts as soon as the navigation does, in parallel with worker start-up if you use navigation preload.

sequenceDiagram
    participant B as Browser
    participant W as Service worker
    participant C as Cache Storage
    participant S as Server
    B->>S: navigation preload: GET /notes/42 + Service-Worker-Navigation-Preload: fragment
    B->>W: start worker, dispatch fetch
    W->>C: match head.html, foot.html
    C-->>W: partials
    W-->>B: stream starts: head.html (FCP)
    S-->>W: fragment HTML (event.preloadResponse)
    W-->>B: stream continues: fragment (LCP element)
    W-->>B: stream ends: foot.html

Composing the stream in the worker

src/sw-streaming.js
// Streaming shell: cached head + network fragment + cached foot.
const PRECACHE = "precache-%BUILD_ID%";
const FRAGMENT_HEADER_VALUE = "fragment";

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      if (self.registration.navigationPreload) {
        await self.registration.navigationPreload.enable();
        // The server returns only the <main> fragment when it sees this value.
        await self.registration.navigationPreload.setHeaderValue(FRAGMENT_HEADER_VALUE);
      }
    })(),
  );
});

self.addEventListener("fetch", (event) => {
  const { request } = event;
  if (request.mode !== "navigate" || request.method !== "GET") return;
  const url = new URL(request.url);
  if (url.origin !== self.location.origin || /^\/(?:api|auth|admin)(?:\/|$)/.test(url.pathname)) {
    return; // must not fall through with preload enabled: see note below
  }
  event.respondWith(streamPage(event));
});

async function streamPage(event) {
  const cache = await caches.open(PRECACHE);
  const [head, foot] = await Promise.all([
    cache.match("/partials/head.html"),
    cache.match("/partials/foot.html"),
  ]);
  if (!head || !foot) {
    // Partials missing: fall back to a normal full page from the network.
    // The preload response cannot be used here, because the server answered
    // it with a bare fragment. Let it settle so Chromium does not log a
    // cancelled-preload warning, then request the full page.
    event.waitUntil(Promise.resolve(event.preloadResponse).catch(() => undefined));
    return fetch(event.request);
  }

  const fragment = contentFragment(event, cache); // starts now, awaited later
  const { readable, writable } = new TransformStream();

  const pump = (async () => {
    try {
      await head.body.pipeTo(writable, { preventClose: true });
      const body = await fragment;
      await body.body.pipeTo(writable, { preventClose: true });
      await foot.body.pipeTo(writable, { preventClose: true });
      await writable.close();
    } catch (error) {
      // The head is already on screen; aborting ends the document early.
      console.error("Streaming failed", error);
      await writable.abort(error).catch(() => undefined);
    }
  })();
  event.waitUntil(pump); // keep the worker alive until the stream is complete

  return new Response(readable, {
    headers: { "Content-Type": "text/html; charset=utf-8" },
  });
}

async function contentFragment(event, cache) {
  try {
    // Navigation preload started this request in parallel with worker start-up.
    const preloaded = await event.preloadResponse;
    if (preloaded) return preloaded;
    // No preload (unsupported or disabled): ask for the fragment explicitly.
    return await fetch(event.request.url, {
      headers: { "Service-Worker-Navigation-Preload": FRAGMENT_HEADER_VALUE },
      credentials: "same-origin",
    });
  } catch {
    // Offline: an offline fragment keeps the shell usable.
    return (
      (await cache.match("/partials/offline.html")) ??
      new Response("<main id=view><h1>You are offline</h1></main>", {
        headers: { "Content-Type": "text/html; charset=utf-8" },
      })
    );
  }
}

Respond to every navigation once preload is enabled

With navigation preload enabled, a navigation the worker does not answer (it returns without calling respondWith()) is sent to the network again as a normal request, and the preload request is wasted. Chromium logs a warning when a preload response is cancelled before it settled. Either answer every in-scope navigation, or disable preload.

The server side of a streaming shell

The server needs to return two representations of every URL: the full page (first visits, crawlers, browsers without a worker) and the bare fragment (requests carrying Service-Worker-Navigation-Preload: fragment). Because the same URL returns different bodies depending on a request header, both responses must carry Vary: Service-Worker-Navigation-Preload, or a CDN or the HTTP cache may serve a fragment to a full-page request.

server/pages.mjs
// Express 5 middleware: one handler, two representations per URL.
import { renderFragment, renderFullPage } from "./render.mjs";

export async function pages(req, res, next) {
  if (req.method !== "GET" || req.path.startsWith("/api/")) return next();
  try {
    const wantsFragment = req.get("Service-Worker-Navigation-Preload") === "fragment";
    const { status, html } = wantsFragment
      ? await renderFragment(req.path, req.user)
      : await renderFullPage(req.path, req.user);
    res
      .status(status)
      .set("Vary", "Service-Worker-Navigation-Preload")
      .set("Cache-Control", "private, no-cache")
      .type("html")
      .send(html);
  } catch (error) {
    next(error);
  }
}

Caveats of streaming shells

  • Status codes are lost. The worker's streamed response is always 200; a 404 fragment still renders inside a 200 document. That does not matter for users, but it does for crawlers, which is one more reason crawlers must get the full server-rendered page (they do: crawlers do not run your service worker).
  • The head is generic. The cached head.html cannot contain the page's <title>, canonical URL or social meta tags. Set the title from the fragment with a tiny inline script, and serve the full page's meta from the server-rendered representation.
  • Errors mid-stream. Once the head is sent, the only way to report a failure is inside the document. Prefer an offline or error fragment over aborting the stream.
  • Streams in the fetch event (ReadableStream response bodies, TransformStream, pipeTo()) work in every engine that supports service workers today, but check old installed browsers if you support them; workbox-streams falls back to waiting for all parts and concatenating them when streams are unsupported.
  • More moving parts. You now have three cached partials, two server representations and a header contract to keep in sync across releases. Streaming Responses covers the pattern, including Workbox's workbox-streams strategy(), in more depth.

App shells in frameworks

Most toolchains either implement the shell for you or assume a server-rendered architecture where the shell is optional. Know which one you have:

Tool How the shell is configured Default behavior to be aware of
Workbox generateSW navigateFallback: "/index.html" plus navigateFallbackAllowlist / navigateFallbackDenylist navigateFallback defaults to null: no shell unless you ask for one
Workbox injectManifest NavigationRoute(createHandlerBoundToURL("/index.html"), { allowlist, denylist }) Throws at runtime if the bound URL is not precached
vite-plugin-pwa (generateSW strategy) workbox.navigateFallback, workbox.navigateFallbackDenylist Defaults navigateFallback to "index.html": an app shell out of the box
Angular service worker (ngsw-config.json) "index": "/index.html" and navigationUrls navigationUrls defaults to all URLs except those with a file extension in the last segment or containing __; navigationRequestStrategy is "performance" (serve the cached index) unless set to "freshness" (network first, index when offline)
Server-rendering frameworks (Next.js, Nuxt, SvelteKit, Remix, Astro and similar) Each route is rendered by the server; a shell only exists if you build one (or export a client-only SPA) Adding a generic navigation fallback in front of them turns every page into the same document; prefer network-first page caching with an offline fallback

For Angular, navigationRequestStrategy: "freshness" is worth knowing: it keeps the shell for offline use while sending navigations to the network when online, which suits apps that rely on server-side redirects. The Framework Integrations page covers each framework's service worker story.

Updating the shell safely

A shell is a snapshot of your application's front end. When you deploy, users keep running the old snapshot until the new worker activates and the page reloads, and that creates version skew problems unique to the model:

  1. Old shell, deleted assets. An old page lazily loads /assets/chunk-settings.1a2b3c.js after you deployed a release that no longer contains it. If the old precache has already been deleted (because a new worker activated) and the server no longer has the file, the import fails. Keep previous releases' fingerprinted assets on the server for a while after each deploy, and handle chunk-load errors by prompting for a reload.
  2. Old shell, new API. The API must stay backward compatible with at least the previous release of the shell, because some users will run it for days. Version your API or add fields without removing them.
  3. skipWaiting() while pages are open. Activating a new worker immediately while old pages are open means the old pages' future requests are answered from the new precache: a new CSS file under an old DOM. Prefer an explicit update prompt that calls skipWaiting() and then reloads, as described in Updating Service Workers.
  4. Precache integrity. The precache is written during install; if any file fails to download, precache() deletes the partial cache and the install fails, so the old version keeps serving. Never catch and ignore precache failures.

Measuring an app shell

An app shell makes some metrics look better and hides costs in others. Measure the phases, not just the totals.

What to watch

Signal How to get it What it tells you
Was the document the shell? document.documentElement.hasAttribute("data-shell") Separates shell loads from server-rendered loads (first visits, deny-listed pages)
Did the worker serve the navigation? performance.getEntriesByType("navigation")[0].workerStart > 0 Controlled vs uncontrolled loads
Worker overhead on the navigation fetchStart - workerStart of the navigation entry Cold-start cost; compare p75 of launches from the home screen
FCP web-vitals onFCP The shell's paint: should be near TTFB on repeat visits
Time from FCP to content performance.mark("content-rendered") minus FCP How long users look at a skeleton
LCP and its subparts web-vitals/attribution onLCP Resource load delay is where the content request hides
Hero element render time (Chromium) Element Timing: elementtiming attribute on the hero Direct measure of the content's key element, not affected by LCP's "stop at first input" rule
INP during start-up onINP attribution loadState Interactions arriving while the app boots
src/shell-metrics.js
import { onFCP, onLCP, onINP } from "web-vitals/attribution";

const nav = performance.getEntriesByType("navigation")[0];
const context = {
  shell: document.documentElement.hasAttribute("data-shell"),
  build: document.documentElement.dataset.build ?? null,
  viaWorker: nav ? nav.workerStart > 0 : false,
  workerTime: nav && nav.workerStart > 0 ? Math.round(nav.fetchStart - nav.workerStart) : 0,
  displayMode: matchMedia("(display-mode: standalone)").matches ? "standalone" : "browser",
};

onFCP((metric) => {
  send({ name: "FCP", value: Math.round(metric.value) });
});

// Skeleton time: from the shell's first paint to the first content render.
// Read the paint entry directly instead of relying on the order in which
// the onFCP callback and this observer happen to run.
const skeletonObserver = new PerformanceObserver((list) => {
  const mark = list.getEntries().find((entry) => entry.name === "content-rendered");
  if (!mark) return;
  skeletonObserver.disconnect(); // only the first content render matters
  const fcpEntry = performance.getEntriesByName("first-contentful-paint")[0];
  // No FCP entry yet means the content rendered before the first paint:
  // the skeleton was never visible.
  const skeletonTime = fcpEntry ? Math.max(0, mark.startTime - fcpEntry.startTime) : 0;
  send({ name: "skeleton-time", value: Math.round(skeletonTime), route: mark.detail?.route });
});
skeletonObserver.observe({ type: "mark", buffered: true });

onLCP((metric) => {
  const a = metric.attribution;
  send({
    name: "LCP",
    value: Math.round(metric.value),
    target: a.target,
    ttfb: Math.round(a.timeToFirstByte),
    loadDelay: Math.round(a.resourceLoadDelay), // grows with JS boot + API latency
    loadDuration: Math.round(a.resourceLoadDuration),
    renderDelay: Math.round(a.elementRenderDelay),
  });
});

onINP((metric) => {
  send({
    name: "INP",
    value: metric.value,
    loadState: metric.attribution.loadState,
    target: metric.attribution.interactionTarget,
  });
});

function send(data) {
  const body = JSON.stringify({ ...context, ...data, url: location.pathname });
  navigator.sendBeacon("/rum", new Blob([body], { type: "application/json" }));
}

The observer above reports the first content-rendered mark and then disconnects, so marks from later soft navigations do not produce extra samples. Sending one beacon per metric keeps the example short; in production, batch them as the RUM module in Core Web Vitals does. On the element itself, <img elementtiming="note-hero" …> makes Chromium emit element entries with renderTime for that image, which you can observe with { type: "element", buffered: true }. Element Timing is Chromium-only.

Segment every metric by shell and viaWorker. The four combinations tell different stories: shell served by the worker (the fast path), shell served by the server (first visits of an SPA), a server-rendered page served by the server (deny-listed or hybrid routes), and a server-rendered page served by the worker (hybrid rendering with page caching). A regression in one segment is invisible in the blended p75. Measuring Performance covers building those dashboards.

Browser support

Support data as of September 2026. For live data see MDN's compatibility tables for ServiceWorker, CacheStorage, NavigationPreloadManager and InstallEvent.addRoutes().

Feature used by an app shell Chrome / Edge Firefox Safari (macOS) Safari (iOS / iPadOS)
Service workers ✅ 40 ✅ 44 ✅ 11.1 ✅ 11.3
Cache Storage ✅ 43 ✅ 41 ✅ 11.1 ✅ 11.3
new Response(readableStream) (streamed bodies) ✅ 52 ✅ 65 ✅ 10.1 ✅ 10.3
TransformStream ✅ 67 ✅ 102 ✅ 14.1 ✅ 14.5
Navigation preload (preloadResponse, setHeaderValue()) ✅ 59 ✅ 99 ✅ 15.4 ✅ 15.4
Static routing (InstallEvent.addRoutes()) ✅ 123 ❌ ✅ 27 ✅ 27
fetchpriority on images ✅ 101 ✅ 132 ✅ 17.2 ✅ 17.2
Navigation API ✅ 102 ✅ 147 ✅ 26.2 ✅ 26.2
Element Timing ✅ 77 ❌ ❌ ❌

Edge versions from 79 follow Chrome. Everything the basic app shell needs (service workers, Cache Storage, fetch) is available in every current engine; static routing and navigation preload are progressive enhancements that the code above feature-detects.

Common pitfalls

  1. Using an app shell for a content site. Fast FCP, slow LCP, and generic HTML for every URL. Cache rendered pages instead.
  2. No deny list. OAuth callbacks, file downloads, robots.txt and server-rendered sections all receive the shell.
  3. Putting content or user data in the shell. It goes stale, leaks between accounts on shared devices, and forces a new precache for every change.
  4. Precaching every route's code. First-time visitors download all of it during install, and the shell parses more on every launch. Precache the core; runtime-cache the rest.
  5. A skeleton that does not match the content. Instant FCP followed by a large layout shift.
  6. Leaving navigation preload enabled from an earlier network-first version: every launch sends a navigation request whose response is thrown away.
  7. Deleting old assets from the server at deploy time. Pages still running the previous shell fail to lazy-load chunks.
  8. Serving the shell for a deep link on a server that returns 404 for it. Without a worker (first visit, private browsing, cleared data) the user sees your server's 404 page. The server must know the router's routes.
  9. Measuring only repeat visits. First visits have no worker and are the slowest path of all; they are part of your p75.
  10. Ignoring INP at start-up. A shell that paints in 100 ms but blocks the main thread for 1.5 s while booting invites taps that go unanswered.
  11. Relying on navigator.onLine alone for the offline state. It reports whether there is a network interface, not whether your server is reachable; treat fetch failures as the source of truth.

Debugging

  • See what the worker serves. In DevTools Network, navigations served from the worker show "(ServiceWorker)" in the Size column. If a deep link shows your server's response instead, the worker did not match it: check the deny list and the scope.
  • Inspect the precache. Application > Cache storage lists precache-<build> with every entry and its X-Precache-Revision header. Missing shell.html means the install failed or the cache was cleaned up too eagerly.
  • Test offline deep links. Check Offline in the Network or Service workers pane and open a URL you have never visited. You should get the shell and the router's offline state, never the browser's error page.
  • Test the first visit. Use a fresh profile or Application > Storage > Clear site data, then load a deep link. This is the path Lighthouse measures by default and the one new users take.
  • Test cold starts. Stop the worker in Application > Service workers (or chrome://serviceworker-internals) before loading, to include start-up in your measurement.
  • Force an update. Update on reload in the Service workers pane installs the new worker on every reload; use it to check that incremental precaching reuses unchanged files (the Network panel should show only changed files being fetched by the worker).
  • Watch for the preload warning. "The service worker navigation preload request was cancelled before 'preloadResponse' settled" means preload is enabled but unused: disable it.

Further reading

On this site

External references