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 (
NavigationRoutewithcreateHandlerBoundToURL, ornavigateFallback),vite-plugin-pwa(whosegenerateSWmode defaultsnavigateFallbacktoindex.html) and Angular's service worker (indexandnavigationUrls) 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-vitalsattribution fieldloadStatetells 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 (withviewport-fit=coverif 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 markedaria-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¶
<!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:
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.
// 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¶
/* 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 readslocation.pathnameto 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 (norespondWith()), 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, whichclaim()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.
Serving deep links before the worker exists¶
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:
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.
// 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.
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();
});
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 } },
},
],
};
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:
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¶
// 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.
// 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.htmlcannot 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 (
ReadableStreamresponse bodies,TransformStream,pipeTo()) work in every engine that supports service workers today, but check old installed browsers if you support them;workbox-streamsfalls 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-streamsstrategy(), 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:
- Old shell, deleted assets. An old page lazily loads
/assets/chunk-settings.1a2b3c.jsafter 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. - 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.
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 callsskipWaiting()and then reloads, as described in Updating Service Workers.- 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 |
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¶
- Using an app shell for a content site. Fast FCP, slow LCP, and generic HTML for every URL. Cache rendered pages instead.
- No deny list. OAuth callbacks, file downloads,
robots.txtand server-rendered sections all receive the shell. - 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.
- 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.
- A skeleton that does not match the content. Instant FCP followed by a large layout shift.
- Leaving navigation preload enabled from an earlier network-first version: every launch sends a navigation request whose response is thrown away.
- Deleting old assets from the server at deploy time. Pages still running the previous shell fail to lazy-load chunks.
- 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.
- Measuring only repeat visits. First visits have no worker and are the slowest path of all; they are part of your p75.
- 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.
- Relying on
navigator.onLinealone 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 itsX-Precache-Revisionheader. Missingshell.htmlmeans 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
- Core Web Vitals: LCP subparts, INP phases and CLS, with the measurement code
- Loading Performance: critical CSS, priority hints and code splitting for the shell
- Measuring Performance: segmenting RUM data by worker and shell usage
- SPA vs MPA PWAs: choosing the architecture before choosing the shell
- Streaming Responses: composing responses in the worker
- Precaching & Runtime Caching: manifests, revisions and cache cleanup
- Navigation Preload: when it helps and why pure shells disable it
- Workbox Fundamentals:
NavigationRoute,precacheAndRoute()and strategies
External references
- Chrome for Developers: The app shell model (Workbox)
- Chrome for Developers: workbox-routing, NavigationRoute
- Chrome for Developers: workbox-precaching
- Chrome for Developers: workbox-streams
- web.dev: Rendering on the Web
- web.dev: Optimize Largest Contentful Paint
- Angular: Service worker configuration
- Vite PWA: Service worker precache and navigateFallback
- W3C: Service Workers specification
- MDN: Using Service Workers