Migrating an Existing Site to a PWA¶
Migrating an existing site to a Progressive Web App means adding a manifest, a service worker and install support to a site that already has users, URLs, caches, cookies and a CDN, without breaking any of them. The safe way to do it is incremental: audit what the site serves, fix HTTPS and headers, add a manifest, ship a first service worker that does nothing except show an offline page when the network fails, and only then add caching one route at a time behind a feature flag with a tested kill switch. This guide walks through each step for both server-rendered multi-page sites and single-page apps, with complete code, server and CDN configuration, a rollout plan, the metrics that prove the migration worked, and the problems teams hit most often.
Key takeaways
- Audit before you write code: inventory every response type (public HTML, personalized HTML, hashed assets, APIs, auth endpoints) and check for service workers that third-party scripts already registered on your origin.
- Your first service worker should handle navigations only, pass them to the network (with navigation preload), and serve a cached offline page only when the network fails. It can't serve stale content, so it can't break the site in the ways caching workers do.
- Keep the worker at one URL forever (
/sw.js), serve it withCache-Control: no-cache, and make sure no SPA rewrite rule or CDN edge cache ever answers that URL with something else. - Add caching in order of increasing risk: hashed static assets, fonts, images, public HTML, then API data. Personalized responses never go into shared caches, and
Vary: Cookiedoesn't protect you in Cache Storage. - Gate registration behind a remotely controlled flag with a percentage rollout, keep a kill-switch worker in the repository, and decide your abort criteria before you start.
- Measure by cohort (flag on vs flag off), not by "controlled vs uncontrolled" page views, or returning-visitor bias will make any worker look like a win.
The migration at a glance¶
Each phase below is independently shippable and reversible. Do not start a phase until the previous one has run in production long enough to trust it.
| Phase | Ships | Exit criteria | How to roll back |
|---|---|---|---|
| 0. Audit | Nothing (inventory, baselines) | Every URL class has a caching decision; baseline field metrics recorded | – |
| 1. HTTPS and headers | HSTS, redirects, correct Content-Type and Cache-Control per path | No mixed content; all paths return intended headers | Revert config |
| 2. Manifest and icons | manifest.webmanifest, icons, <head> tags, standalone-mode fixes | Installable in Chrome and Edge; icons correct on Android and iOS | Remove the <link rel="manifest"> |
| 3. Offline fallback worker | /sw.js that handles failed navigations only | No change in error rates or TTFB; offline page renders | Flag off (page unregisters), or kill-switch worker |
| 4. Incremental caching | Static assets, then images, then public HTML, then APIs | Faster repeat loads, no stale-content bugs, stable storage usage | Ship previous worker, or kill switch |
| 5. Auth and personalization hardening | Per-user cache rules, logout cleanup | No personalized response cached under a shared key | Kill switch clears caches |
| 6. Engagement (optional) | Install UI, push, badging | Opt-in and retention metrics | Feature flags per capability |
flowchart LR
A["0 Audit"] --> B["1 HTTPS and headers"]
B --> C["2 Manifest and icons"]
C --> D["3 Offline-fallback worker"]
D --> E["4 Incremental caching"]
E --> F["5 Auth hardening"]
F --> G["6 Install UX and push"]
D -. "abort criteria hit" .-> K["Kill switch"]
E -. "abort criteria hit" .-> K Phases 2 and 3 can ship in the same release. Phase 5 is listed separately because it deserves its own review, but in practice you design it alongside phase 4: you must know which responses are personalized before you cache any HTML.
Step 1: Audit the existing site¶
Inventory your responses by class¶
A service worker makes a decision for every request in its scope. Before writing one, you need to know what kinds of requests exist. Group your URLs into classes and record, for each, how the server caches it today and what the worker should do with it.
| Class | Examples | Typical current headers | Worker decision (initial) |
|---|---|---|---|
| Public HTML | /, /blog/*, /products/* | Cache-Control: max-age=0 or short s-maxage at the CDN | Network, offline page on failure. Later: network-first with cache |
| Personalized HTML | /account, /cart, dashboards | private, no-store (hopefully) | Network only. Never cache under a shared key |
| Auth endpoints | /login, /logout, /oauth/callback, SAML ACS | no-store, Set-Cookie, redirects | Never intercept beyond the offline fallback; never cache |
| Hashed static assets | /assets/app.3f9a1c.js | max-age=31536000, immutable | Cache-first (phase 4) |
| Unhashed static assets | /js/app.js, /css/site.css | Short max-age, ETag | Stale-while-revalidate, or fix the build to hash them |
| Images and media | /images/*, CMS uploads, video | Long max-age, sometimes on another origin | Stale-while-revalidate with size limits; skip Range requests |
| Fonts | Self-hosted or third-party | Long max-age | Cache-first |
| API responses | /api/*, GraphQL | Mixed; often no-store | Network only until phase 4b, then per endpoint |
| Third-party scripts | Analytics, tag managers, chat widgets | Outside your control | Not intercepted (or network only) |
| Downloads and streams | PDFs, exports, SSE, WebSockets | Various | Not intercepted. WebSockets never go through a worker |
Two findings from this inventory change the plan more than any other. First, personalized HTML served with shared-cache-friendly headers: if /account is served with Cache-Control: public today, a CDN problem already exists, and a caching worker would make it worse. Fix the headers first. Second, unhashed static assets: without content hashes in file names, you cannot safely cache-first anything, so fixing the build pipeline becomes a phase 4 prerequisite.
Find service workers you already have¶
Many sites already run a service worker without knowing it. Push-notification vendors, some A/B testing and analytics tools, and older framework defaults register workers on the root scope. There can be only one registration per scope, so your new /sw.js at scope / would silently replace a vendor's worker if their script URL is different, breaking their push delivery, or be replaced by it.
Run this in the console on production pages, in a profile where you have used the site normally:
// Lists every service worker registration for this origin, with its scope,
// script URL and the state of each version.
const regs = await navigator.serviceWorker.getRegistrations();
console.table(
regs.map((r) => ({
scope: r.scope,
active: r.active?.scriptURL ?? "-",
waiting: r.waiting?.scriptURL ?? "-",
installing: r.installing?.scriptURL ?? "-",
navigationPreload: "navigationPreload" in r,
})),
);
Also search your code and tag-manager configuration for serviceWorker.register(. If a vendor worker exists on /, you have two options: import the vendor's script into your worker with importScripts() (most push vendors document this) and register only yours, or move one of the two to a narrower scope. Decide before phase 3. Registration & Scope explains how scopes match and why the longest scope wins.
Server-rendered sites vs single-page apps¶
The steps are the same, but the details differ. Keep this table in mind throughout the guide.
| Concern | Server-rendered / multi-page | Single-page app |
|---|---|---|
| What a navigation returns | A full, often personalized HTML page per URL | The same index.html shell for every route (via a server rewrite) |
| Offline story | Previously visited pages from cache, offline page otherwise | Cached shell plus cached or local data |
| Caching HTML | Risky: per-URL, may contain user data | Straightforward: one shell file, data comes from APIs |
| Update hazards | Few: each page loads its own assets | Old shell referencing deleted chunks (chunk-load errors) |
| Server rewrite hazards | Rare | The catch-all rewrite can answer /sw.js or /manifest.webmanifest with index.html |
| Where personalization lives | In HTML | In API responses |
| Useful patterns | Network-first HTML, streaming partials | App shell, offline-first data |
SPA vs MPA PWAs covers the architectural trade-offs. This guide assumes you keep your current architecture during the migration; changing it at the same time multiplies the risk.
Automate the header audit¶
Header problems are easier to find with a script than by clicking through DevTools. The script below requests a list of URLs, follows redirects manually so it can report each hop, and flags the problems that matter for a PWA migration. It runs on Node.js 20 or later with no dependencies.
#!/usr/bin/env node
// Usage: node scripts/pwa-migration-audit.mjs https://www.example.com urls.txt
// urls.txt: one path or absolute URL per line (# comments allowed).
// Reports redirects, caching headers, cookies and PWA-specific paths.
import { readFile, writeFile } from "node:fs/promises";
const [origin, listFile] = process.argv.slice(2);
if (!origin || !listFile) {
console.error("usage: node pwa-migration-audit.mjs <origin> <url-list-file>");
process.exit(2);
}
// Paths every migration should check even if they are not in the list.
const ALWAYS = ["/", "/sw.js", "/service-worker.js", "/manifest.webmanifest",
"/manifest.json", "/offline.html", "/robots.txt"];
const MAX_HOPS = 10;
async function fetchChain(url) {
const hops = [];
let current = url;
for (let i = 0; i < MAX_HOPS; i++) {
let res;
try {
res = await fetch(current, {
redirect: "manual",
headers: { "user-agent": "pwa-migration-audit/1.0", accept: "text/html,*/*" },
signal: AbortSignal.timeout(15000),
});
} catch (err) {
hops.push({ url: current, error: err.message });
return hops;
}
const h = res.headers;
// getSetCookie() returns each Set-Cookie header separately.
const cookies = typeof h.getSetCookie === "function" ? h.getSetCookie() : [];
hops.push({
url: current,
status: res.status,
contentType: h.get("content-type") ?? "",
cacheControl: h.get("cache-control") ?? "",
vary: h.get("vary") ?? "",
etag: h.has("etag"),
lastModified: h.has("last-modified"),
setCookie: cookies.map((c) => c.split("=")[0]),
hsts: h.get("strict-transport-security") ?? "",
csp: h.get("content-security-policy") ?? "",
swAllowed: h.get("service-worker-allowed") ?? "",
location: h.get("location") ?? "",
});
await res.body?.cancel(); // don't download bodies we don't read
if (res.status >= 300 && res.status < 400 && h.get("location")) {
current = new URL(h.get("location"), current).href;
continue;
}
return hops;
}
hops.push({ url: current, error: "too many redirects" });
return hops;
}
function findings(path, hops) {
const out = [];
const last = hops.at(-1);
if (last.error) return [`request failed: ${last.error}`];
if (hops.length > 1) out.push(`redirect chain: ${hops.map((h) => h.status).join(" -> ")}`);
if (hops.some((h) => h.url.startsWith("http://") && h.status < 300)) {
out.push("served over plain HTTP without redirect");
}
const cc = last.cacheControl.toLowerCase();
const isHTML = last.contentType.includes("text/html");
if (isHTML && last.setCookie.length && !/private|no-store/.test(cc)) {
out.push("HTML sets cookies but is not private/no-store (shared caches may store it)");
}
if (isHTML && /public/.test(cc) && last.vary.toLowerCase().includes("cookie")) {
out.push("public HTML varies on Cookie: probably personalized, never cache in the worker");
}
if (!last.hsts && path === "/") out.push("no Strict-Transport-Security header");
if (/\/(sw|service-worker)\.js$/.test(path) && last.status === 200) {
if (!/javascript/.test(last.contentType)) {
out.push(`worker script served as ${last.contentType}: registration will fail`);
}
if (isHTML) out.push("worker URL answered with HTML (SPA rewrite?)");
if (/max-age=(?!0\b)\d+/.test(cc) && !/no-cache/.test(cc)) {
out.push(`worker script cacheable (${cc}): check CDN edge TTL`);
}
}
if (/manifest/.test(path) && last.status === 200 && isHTML) {
out.push("manifest URL answered with HTML (SPA rewrite?)");
}
if (/\.[0-9a-f]{6,}\./i.test(path) && !/immutable|max-age=\d{6,}/.test(cc)) {
out.push("hashed asset without long-lived caching");
}
if (last.vary.includes("*")) out.push("Vary: * (cache.add() will reject this response)");
return out;
}
const listed = (await readFile(listFile, "utf8"))
.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
const paths = [...new Set([...ALWAYS, ...listed])];
const report = [];
for (const p of paths) {
const url = new URL(p, origin).href;
const hops = await fetchChain(url);
const last = hops.at(-1);
report.push({
path: new URL(url).pathname,
status: last.status ?? "ERR",
type: (last.contentType ?? "").split(";")[0],
cacheControl: last.cacheControl ?? "",
vary: last.vary ?? "",
cookies: (last.setCookie ?? []).join(","),
issues: findings(new URL(url).pathname, hops).join(" | "),
});
}
// Also check that plain HTTP redirects to HTTPS.
const httpHops = await fetchChain(origin.replace(/^https:/, "http:"));
report.push({
path: "(http://)",
status: httpHops[0].status ?? "ERR",
type: "",
cacheControl: "",
vary: "",
cookies: "",
issues: httpHops[0].location?.startsWith("https://")
? ""
: "HTTP does not redirect straight to HTTPS",
});
console.table(report);
await writeFile("pwa-migration-audit.json", JSON.stringify(report, null, 2));
console.log("Full report written to pwa-migration-audit.json");
Run it against production and staging, and keep the JSON as the "before" snapshot. Re-run it after every phase: header regressions are the most common way a migration goes wrong without anyone touching the worker.
Record baseline metrics¶
You can't show that the migration helped without numbers from before it started. Capture at least two weeks of:
- Field Core Web Vitals (LCP, INP, CLS) and TTFB at the 75th percentile, split by new vs returning visitors and by device class. See Core Web Vitals and Measuring Performance.
- Error rates: JavaScript errors, failed navigations, HTTP 5xx rates by path.
- Engagement: return-visit rate, pages per session, conversion for your key funnel.
- Traffic shape: share of sessions by browser engine and by iOS vs Android vs desktop, so you know which capabilities matter (see When to Build a PWA).
Step 2: HTTPS and security headers¶
Service workers, the manifest's install flow, push and most modern capabilities require a secure context. localhost and other loopback addresses count as secure for development. Everything else needs HTTPS, on every origin that serves pages you want the worker to control.
What to verify and fix:
- Every HTTP URL redirects to HTTPS in one hop, with a
301or308. Chained redirects (http://example.com→https://example.com→https://www.example.com) cost a round trip each and confusestart_urlandscopeif they point at the wrong host. - HSTS (
Strict-Transport-Security: max-age=31536000; includeSubDomains) so browsers stop making the insecure request at all. Only addpreloadonce you are sure every subdomain supports HTTPS. - No mixed content. Pages with blocked mixed content break silently in installed apps where users can't see the address bar warning. A worker cannot fetch
http://URLs either:fetch()from a secure context to an insecure URL fails. - Cookies are
Secureand, for session cookies,HttpOnlyandSameSite=Laxor stricter. The worker never seesCookieheaders, but it forwards requests that carry them. - One canonical host. A worker registered on
https://www.example.comdoes nothing forhttps://example.com. Pick one host, redirect the other, and use it instart_url,scopeandid.
The worker must be on your page's origin¶
A service worker script must be same-origin with the page that registers it. You can't serve sw.js from a CDN hostname such as static.example.net. If your HTML is served by the CDN under your own hostname, that's fine: origin means scheme, host and port as the browser sees them, not which machine answers.
By default the maximum scope is the directory containing the script: /static/sw.js can control /static/ and below, but not /. Serve the worker at the root, or send Service-Worker-Allowed: / with the script response and pass { scope: "/" } to register(). Root placement is simpler and avoids a header that's easy to lose in a CDN migration.
Content Security Policy¶
If the site has a CSP, check three directives before phase 3:
| Directive | Why it matters | Typical value |
|---|---|---|
worker-src (falls back to child-src, then script-src) | Governs which URLs can be registered as a service worker | worker-src 'self' |
manifest-src (falls back to default-src) | Governs the manifest fetch | manifest-src 'self' |
connect-src | Governs fetch() from pages; the worker's own fetches are governed by the CSP delivered with sw.js | Include analytics and API origins |
The worker's CSP comes from the response headers of sw.js itself, not from the page. Content Security Policy covers this in detail.
Step 3: Add a manifest and icons¶
The manifest is a static JSON file and the lowest-risk part of the migration. Chromium-based browsers use it to decide installability and to build the installed app. Safari on iOS and iPadOS 26 and later lets users add any site as a web app, and reads name, start_url, scope, display, id and theme_color from the manifest when present. It uses manifest icons (since iOS 15.4) only when the page has no apple-touch-icon, and only icons whose purpose is any or unset: Safari ignores maskable icons. Web App Manifest and Members Reference document every member.
{
"id": "/",
"name": "Example Store",
"short_name": "Example",
"description": "Browse products, track orders and manage your account.",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#0b57d0",
"lang": "en",
"dir": "ltr",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
{ "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"shortcuts": [
{ "name": "Track an order", "url": "/orders?source=pwa-shortcut",
"icons": [{ "src": "/icons/shortcut-orders-96.png", "sizes": "96x96", "type": "image/png" }] }
]
}
Decisions in this file that are hard to change later:
idis the app's identity. If you omit it, browsers derive it fromstart_url, and changingstart_urllater (for example, to add a tracking parameter) would make browsers treat it as a different app. Setidexplicitly from day one. App Identity & Updates explains the rules.start_urlwith a query parameter such as?source=pwalets analytics attribute launches from the installed app. Make sure the parameter does not create duplicate indexable URLs (a canonical link on the page handles that) and that your CDN cache key ignores it or you get a separate cache entry for every variant.scopedecides which URLs stay inside the app window. Links outside scope open in a browser tab or an in-app browser view. If you have separate sections on other paths or hosts (checkout onpay.example.com, help center on a SaaS domain), users will leave the app window when they follow those links.- Icons: provide
anyicons at 192 and 512 pixels and separatemaskableicons with the content inside the safe zone. Icons & Maskable Icons covers sizes and the safe zone.
Head tags for every page template¶
Add these to every page template, or to index.html in a SPA:
<!-- The manifest. Add crossorigin="use-credentials" only if the manifest
URL requires cookies (for example behind an authenticating proxy):
manifests are fetched without credentials by default, even same-origin. -->
<link rel="manifest" href="/manifest.webmanifest">
<!-- Browser UI color in tabs, and the title bar color on some platforms. -->
<meta name="theme-color" content="#0b57d0">
<!-- iOS uses this icon for the Home Screen and ignores manifest icons when
it is present. 180x180, opaque background, no transparency. -->
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180.png">
<!-- Needed for the viewport to behave in standalone mode. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
Serve the manifest with Content-Type: application/manifest+json (browsers also accept application/json) and a short cache lifetime. Chromium re-checks the manifest of installed apps when the user launches them and updates name, icons and colors according to its update rules, so a manifest stuck in a CDN cache for a year delays those changes.
A manifest that's protected by cookies is a common staging-environment surprise. The manifest request carries no credentials unless you set crossorigin="use-credentials", so behind a login wall or an authenticating proxy it returns a login page or a 401, and the browser reports a manifest parse error.
Fix what breaks in standalone mode¶
Once a manifest with display: standalone is live, some users will open the site without browser UI. On iOS 26 and later, users can do that even without a manifest. Check these before you promote installation:
-
Back navigation. Standalone windows on iOS have no back button. Android has the system back gesture, desktop has keyboard shortcuts, but on iPhone a page without an in-app back affordance is a dead end. Show one in standalone mode:
-
Links with
target="_blank"andwindow.open()open outside the app window. That's usually right for external sites, wrong for your own pages. Remove_blankfrom same-scope links. - External sign-in. OAuth or SAML redirects to an identity provider on another host leave the app's scope. How the browser presents out-of-scope pages differs by platform (Chromium keeps them in the app window with a minimal toolbar showing the origin), so test the full sign-in round trip in each installed context, including passkeys (Authentication & Passkeys).
- Printing, downloads and "open in new tab" affordances behave differently without browser chrome. Provide explicit buttons where users need them.
- Safe areas. With
viewport-fit=cover, useenv(safe-area-inset-*)padding for fixed headers and footers. App-Like UX Patterns has the details.
Step 4: A minimal-risk first service worker¶
The first worker you deploy should be boring. Its only job is to replace the browser's network error page with your own offline page. It must not change what users see when the network works.
The rules that make it safe:
- Handle navigations only. Every other request (scripts, styles, images, API calls) is not touched, so the worker can't serve stale assets or break APIs.
- Network always wins. The navigation goes to the network exactly as before. The worker steps in only when
fetch()rejects, which happens on network failure, not on HTTP errors: a 404 or 500 from your server passes through unchanged. - Use navigation preload so the navigation request starts in parallel with worker startup instead of waiting for it. Without it, every navigation pays the worker's boot time. Navigation Preload explains the mechanism.
- Skip non-GET navigations. Form
POSTnavigations stay entirely with the browser, so nothing changes for checkout or login forms. - Keep the offline page self-contained: inline CSS, no external scripts, no images that aren't also cached.
- Keep the worker's URL stable.
/sw.jstoday and forever. Browsers only check the registered URL for updates, so a kill switch must be deployable at the same URL.
sequenceDiagram
participant Page
participant SW as Service worker
participant Net as Network
participant Cache as Cache Storage
Page->>SW: navigate /products/42 (GET)
par Navigation preload
SW->>Net: preload request /products/42
end
alt Network OK (any HTTP status)
Net-->>SW: response (200, 404, 500...)
SW-->>Page: same response, unchanged
else Network error
Net--xSW: TypeError
SW->>Cache: match /offline.html
Cache-->>SW: offline page
SW-->>Page: offline page (status 503)
end The offline page¶
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>You're offline · Example Store</title>
<style>
/* Everything inline: this page must render with no network at all. */
:root { color-scheme: light dark; --accent: #0b57d0; }
body { font: 16px/1.5 system-ui, sans-serif; margin: 0; display: grid;
min-height: 100svh; place-items: center; padding: 16px; box-sizing: border-box; }
main { max-width: 32rem; text-align: center; }
h1 { font-size: 1.5rem; margin: 0 0 .5rem; }
button { font: inherit; padding: .6rem 1.2rem; border-radius: .5rem; border: 0;
background: var(--accent); color: #fff; cursor: pointer; }
#status { min-height: 1.5em; margin-top: 1rem; }
</style>
</head>
<body>
<main>
<h1>You're offline</h1>
<p>This page isn't available without a connection. Check your network and try again.</p>
<button id="retry" type="button">Try again</button>
<p id="status" role="status" aria-live="polite"></p>
</main>
<script>
// Record the impression so the next online page view can report it
// (see "Measuring impact"). localStorage can throw in private modes.
try {
const n = Number(localStorage.getItem("offline-fallback-views") || 0);
localStorage.setItem("offline-fallback-views", String(n + 1));
} catch {}
const status = document.getElementById("status");
// The worker serves this page at the URL the user asked for, so a reload
// retries the original navigation.
document.getElementById("retry").addEventListener("click", () => {
status.textContent = "Retrying…";
location.reload();
});
// navigator.onLine is only a hint (true on a captive portal), but the
// "online" event is a good moment to retry automatically.
addEventListener("online", () => location.reload());
</script>
</body>
</html>
The page is served at the URL the user navigated to, not at /offline.html, so reloading retries the original request. Inline scripts need a CSP nonce or hash if your policy disallows 'unsafe-inline'; because this is a static file, a hash is the simplest option (a nonce would be frozen into the cached copy and could never match a fresh policy).
Two properties of this page matter because of how the worker serves it. First, the worker builds a new Response from the cached one, and a synthesized response carries only the headers the worker copies into it. The worker below copies the stored headers, so the Content-Security-Policy your server sent with offline.html still applies; if you write your own fallback, don't replace the headers wholesale or the page runs with no CSP at all. Second, relative URLs in the page resolve against the URL the user asked for (say /products/42), not /offline.html, so use root-relative or inline resources only.
The first worker: offline fallback only¶
// Phase 3 worker. Handles failed navigations only; everything else goes to
// the network exactly as if no worker existed.
const VERSION = "v1";
const CACHE_PREFIX = "example-"; // used by cleanup and kill switch
const OFFLINE_CACHE = `${CACHE_PREFIX}offline-${VERSION}`;
const OFFLINE_URL = "/offline.html";
self.addEventListener("install", (event) => {
// Static Routing API (Chrome 123+, Safari 27+): tell the browser not to
// start this worker for anything except navigations. Call it synchronously
// in the handler. The `not` condition needs Chrome 127+; older versions
// reject the promise (installation still succeeds), and the fetch
// handler's early return below does the same job more slowly.
if (typeof event.addRoutes === "function") {
event
.addRoutes({ condition: { not: { requestMode: "navigate" } }, source: "network" })
.catch((err) => console.warn("Static routes not registered:", err));
}
event.waitUntil(
(async () => {
const cache = await caches.open(OFFLINE_CACHE);
// cache: "reload" bypasses the HTTP cache so we never store a stale copy.
// add() rejects on non-2xx, which fails the install: better than
// activating a worker without its fallback.
await cache.add(new Request(OFFLINE_URL, { cache: "reload" }));
})(),
);
// Safe here because v1 changes nothing about how pages or assets load.
// Revisit this line before shipping a worker that caches assets.
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
// Remove caches from older versions of *this* worker only. The prefix
// keeps us from deleting caches that other code on the origin owns.
const names = await caches.keys();
await Promise.all(
names
.filter((n) => n.startsWith(CACHE_PREFIX) && n !== OFFLINE_CACHE)
.map((n) => caches.delete(n)),
);
// Start navigation requests in parallel with worker boot-up.
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
})(),
);
// No clients.claim(): uncontrolled pages lose nothing, and the next
// navigation is handled by this worker anyway.
});
self.addEventListener("fetch", (event) => {
const { request } = event;
// Only GET navigations. Subresources, API calls and form POSTs fall
// through to the browser untouched because respondWith() is not called.
if (request.mode !== "navigate" || request.method !== "GET") return;
event.respondWith(handleNavigation(event));
});
async function handleNavigation(event) {
try {
// Use the preload response if the browser started one. Awaiting it even
// when we don't need it avoids a "preload cancelled" console warning.
const preloaded = await event.preloadResponse;
if (preloaded) return preloaded;
return await fetch(event.request);
} catch (error) {
// fetch() rejects only on network failure (offline, DNS, TLS, reset).
// HTTP 4xx/5xx responses resolve and were returned above unchanged.
const cache = await caches.open(OFFLINE_CACHE);
const cached = await cache.match(OFFLINE_URL);
if (cached) {
// Re-wrap with 503 so analytics and the browser don't treat the
// fallback as a successful load of the requested URL. Copy the stored
// headers so the Content-Security-Policy sent with offline.html still
// applies: a synthesized Response only has the headers you give it.
const headers = new Headers(cached.headers);
headers.set("Cache-Control", "no-store");
return new Response(cached.body, { status: 503, statusText: "Offline", headers });
}
// The cache was evicted: fall back to a minimal inline page.
return new Response(
"<!doctype html><meta charset=utf-8><title>Offline</title>" +
"<p>You're offline. Reload when you're back online.</p>",
{ status: 503, headers: { "Content-Type": "text/html; charset=utf-8" } },
);
}
}
A few details are easy to get wrong:
- Don't add an empty
fetchhandler to subresources "for completeness." A worker with a fetch handler is started for every request it could handle; returning early still costs startup when the worker isn't running. That's why the static route above exists: in supporting browsers, non-navigation requests never wake the worker. Static Routing API documents the conditions and their support. - Don't return
cacheddirectly for a navigation if it might be a redirected response.offline.htmlfetched withcache.add()is fine unless your server redirects it (for example, adding a trailing slash or forcing a locale), in which case the stored response hasredirected === trueand browsers refuse it for navigations. Re-wrapping in a newResponse, as above, sidesteps that. Handling Fetch Events lists every response the browser rejects. - Navigation preload changes the request. Preload requests carry a
Service-Worker-Navigation-Preload: trueheader. If your server or CDN varies responses on unknown headers, or a WAF blocks unfamiliar ones, check that preload responses are identical to normal navigations.
Registering the worker behind a flag¶
Registration is where you control rollout. The script below reads a remotely controlled configuration, assigns each browser to a stable rollout bucket, and registers, unregisters or kills the worker accordingly. Because the v1 worker never caches HTML, the page always comes from the network and this script always runs the latest configuration, which makes the page-side flag a reliable off switch for phase 3.
// Loaded on every page (defer, or at the end of <body>).
// /pwa-config.json example:
// { "serviceWorker": "on", "rolloutPercent": 10 }
// serviceWorker: "on" | "off" (unregister) | "kill" (unregister + delete caches)
const SW_URL = "/sw.js";
const CONFIG_URL = "/pwa-config.json";
const CACHE_PREFIX = "example-";
const BUCKET_KEY = "pwa-rollout-bucket";
function rolloutBucket() {
// A stable number in [0, 100) per browser profile. Stored so the same
// browser stays in the same cohort across visits.
try {
let b = localStorage.getItem(BUCKET_KEY);
if (b === null) {
b = String(Math.floor(crypto.getRandomValues(new Uint32Array(1))[0] % 100));
localStorage.setItem(BUCKET_KEY, b);
}
return Number(b);
} catch {
return 99; // no storage: treat as the last bucket (enabled only at 100%)
}
}
async function loadConfig() {
// no-store: the flag must never be served from any cache.
const res = await fetch(CONFIG_URL, { cache: "no-store", credentials: "omit" });
if (!res.ok) throw new Error(`config HTTP ${res.status}`);
return res.json();
}
async function ourRegistrations() {
const regs = await navigator.serviceWorker.getRegistrations();
const target = new URL(SW_URL, location.href).href;
return regs.filter((r) =>
[r.active, r.waiting, r.installing].some((w) => w?.scriptURL === target),
);
}
async function disable({ deleteCaches }) {
const regs = await ourRegistrations();
await Promise.all(regs.map((r) => r.unregister()));
if (deleteCaches && "caches" in self) {
const names = await caches.keys();
await Promise.all(names.filter((n) => n.startsWith(CACHE_PREFIX)).map((n) => caches.delete(n)));
}
return regs.length;
}
async function initServiceWorker() {
if (!("serviceWorker" in navigator)) return { state: "unsupported" };
let config;
try {
config = await loadConfig();
} catch (err) {
// Offline or config endpoint down: change nothing. An existing
// registration keeps working; a new one waits for the next visit.
return { state: "config-unavailable", error: String(err) };
}
const bucket = rolloutBucket();
const mode = config.serviceWorker ?? "off";
const enabled = mode === "on" && bucket < (config.rolloutPercent ?? 0);
if (mode === "kill") {
const removed = await disable({ deleteCaches: true });
return { state: "killed", removed, bucket };
}
if (!enabled) {
const removed = await disable({ deleteCaches: false });
return { state: "disabled", removed, bucket };
}
try {
const reg = await navigator.serviceWorker.register(SW_URL, {
scope: "/",
// Default is "imports": the browser bypasses the HTTP cache for sw.js
// itself during update checks. Stated explicitly for readers.
updateViaCache: "imports",
});
return { state: "registered", scope: reg.scope, bucket };
} catch (err) {
// SecurityError: origin, scope or MIME type problems. TypeError: 404,
// network failure or a script that throws during evaluation.
// Report it: this is a deploy problem, not a user one.
return { state: "register-failed", error: `${err.name}: ${err.message}`, bucket };
}
}
// Register after the load event so worker installation (which downloads
// and caches files) never competes with the page's own critical requests.
const ready = new Promise((resolve) => {
if (document.readyState === "complete") resolve();
else addEventListener("load", resolve, { once: true });
});
ready
.then(initServiceWorker)
// getRegistrations() or unregister() can reject (for example when storage
// is blocked); record that instead of leaving an unhandled rejection.
.catch((err) => ({ state: "error", error: `${err.name}: ${err.message}` }))
.then((result) => {
// Expose for analytics and debugging; see "Measuring impact".
window.__swRollout = result;
document.dispatchEvent(new CustomEvent("sw-rollout", { detail: result }));
});
pwa-config.json can be a static file you edit and deploy, an endpoint backed by your feature-flag service, or a value rendered into the HTML by the server. What matters is that it's served with Cache-Control: no-store and never cached by the worker.
The page-side switch only works while HTML comes from the network
Unregistering from the page requires the page's own code to run with the new configuration. The v1 worker always fetches HTML from the network, so that holds. From the moment a worker serves HTML from cache (phase 4), a page-side flag can arrive too late or not at all. From then on your real off switch is the kill-switch worker described in the rollout section, deployed at /sw.js.
Step 5: Add caching incrementally¶
Once the fallback worker has run in production without side effects, add caching one route class at a time, in order of increasing risk. Ship each step as its own worker version and watch it for at least one full release cycle before the next.
| Order | Route class | Strategy | Why this order |
|---|---|---|---|
| 5a | Content-hashed JS, CSS, fonts | Cache-first | A hashed URL never changes content, so a cached copy can't be stale |
| 5b | Same-origin images | Stale-while-revalidate, capped entries | Staleness is visible but harmless; size is the main risk |
| 5c | Public HTML | Network-first with timeout, cached copy on failure or slow network | Offline and "lie-fi" benefit, but staleness and personalization risks |
| 5d | Selected API responses | Per endpoint: network-first, or stale-while-revalidate for reference data | Highest coupling to app logic and user data |
Caching Strategies explains each strategy's behavior and failure modes; Precaching & Runtime Caching covers build-time precache manifests. The worker below implements 5a to 5c with no dependencies. The Workbox tab implements the same routes with Workbox.
// Phase 4 worker. Adds cache-first for hashed assets, stale-while-revalidate
// for images, and network-first with a timeout for an allowlist of public
// pages. Everything else still goes to the network untouched.
const VERSION = "v2";
const CACHE_PREFIX = "example-";
const CACHE = {
offline: `${CACHE_PREFIX}offline-${VERSION}`,
assets: `${CACHE_PREFIX}assets`, // hashed names never change: no version
images: `${CACHE_PREFIX}images`,
pages: `${CACHE_PREFIX}pages-${VERSION}`, // HTML is tied to an asset set
};
const CURRENT = new Set(Object.values(CACHE));
const LIMITS = { [CACHE.assets]: 300, [CACHE.images]: 120, [CACHE.pages]: 50 };
const OFFLINE_URL = "/offline.html";
const NAV_TIMEOUT_MS = 4000;
// Allowlist, not denylist: a page is cached only if you have confirmed
// that its HTML never contains user-specific content.
const CACHEABLE_PAGES = [/^\/$/, /^\/blog(\/|$)/, /^\/products\/[^/]+$/, /^\/help(\/|$)/];
const HASHED_ASSET = /^\/assets\/.+\.[0-9a-f]{8,}\.(?:js|css|woff2|svg|png|webp|avif)$/i;
const TRACKING_PARAM = /^(utm_.+|source|fbclid|gclid|msclkid)$/;
self.addEventListener("install", (event) => {
if (typeof event.addRoutes === "function") {
// Keep cross-origin requests and APIs away from the worker entirely.
// Rejected as a whole in engines without `not` (Chrome < 127); the
// fetch handler below makes the same decisions in that case.
event
.addRoutes([
{ condition: { not: { urlPattern: "/*" } }, source: "network" },
{ condition: { urlPattern: "/api/*" }, source: "network" },
])
.catch((err) => console.warn("Static routes not registered:", err));
}
event.waitUntil(
caches
.open(CACHE.offline)
.then((cache) => cache.add(new Request(OFFLINE_URL, { cache: "reload" }))),
);
// Safe for this worker: cached assets are immutable and HTML is
// network-first. Switch to a prompt-to-update flow before precaching an
// app shell (see Updating Service Workers).
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const names = await caches.keys();
await Promise.all(
names
.filter((n) => n.startsWith(CACHE_PREFIX) && !CURRENT.has(n))
.map((n) => caches.delete(n)),
);
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
})(),
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (request.mode === "navigate") {
event.respondWith(handleNavigation(event, url));
return;
}
if (url.origin !== self.location.origin) return; // third parties: untouched
if (request.headers.has("range")) return; // media byte ranges: untouched
if (HASHED_ASSET.test(url.pathname)) {
event.respondWith(cacheFirst(event, CACHE.assets));
} else if (request.destination === "image") {
event.respondWith(staleWhileRevalidate(event, CACHE.images));
}
// Anything else (APIs, unhashed files) is not handled: network as before.
});
// ---------- Strategies ----------
async function cacheFirst(event, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(event.request);
if (cached) return cached;
const response = await fetch(event.request);
if (isCacheable(response)) {
event.waitUntil(putAndTrim(cache, event.request, response.clone(), cacheName));
}
return response;
}
async function staleWhileRevalidate(event, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(event.request);
const network = fetch(event.request).then((response) => {
if (isCacheable(response)) {
event.waitUntil(putAndTrim(cache, event.request, response.clone(), cacheName));
}
return response;
});
if (cached) {
event.waitUntil(network.catch(() => {})); // refresh in the background
return cached;
}
return network;
}
async function handleNavigation(event, url) {
const key = pageCacheKey(url);
const network = (async () => (await event.preloadResponse) || fetch(event.request))();
if (!key) {
// Not on the allowlist: phase 3 behavior.
try {
return await network;
} catch {
return offlineResponse();
}
}
// Save a fresh copy whenever the network answers, even after a timeout.
const update = network.then((response) => {
if (isCacheable(response)) {
const copy = response.clone(); // clone before the page reads the body
event.waitUntil(caches.open(CACHE.pages).then((c) => putAndTrim(c, key, copy, CACHE.pages)));
}
return response;
});
event.waitUntil(update.then(() => {}, () => {}));
const timeout = new Promise((resolve) => setTimeout(resolve, NAV_TIMEOUT_MS, "timeout"));
try {
const winner = await Promise.race([update, timeout]);
if (winner !== "timeout") return winner;
// Slow network: serve the cached copy if there is one, else keep waiting.
return (await matchPage(key)) ?? (await update);
} catch {
return (await matchPage(key)) ?? offlineResponse();
}
}
// ---------- Helpers ----------
function pageCacheKey(url) {
if (url.origin !== self.location.origin) return null;
if (!CACHEABLE_PAGES.some((re) => re.test(url.pathname))) return null;
// Drop tracking parameters so /?utm_source=x and / share one entry.
const clean = new URL(url);
for (const name of [...clean.searchParams.keys()]) {
if (TRACKING_PARAM.test(name)) clean.searchParams.delete(name);
}
clean.hash = "";
// A bare Request is used for both put() and match(), so Vary on
// Accept compares "absent" with "absent" consistently.
return new Request(clean.href);
}
async function matchPage(key) {
const cache = await caches.open(CACHE.pages);
const cached = await cache.match(key);
// Re-wrap: a stored response with redirected === true can't answer a
// navigation, and the copy lets us mark it as coming from the cache.
if (!cached) return undefined;
const headers = new Headers(cached.headers);
headers.set("X-SW-Cache", "hit");
return new Response(cached.body, { status: cached.status, statusText: cached.statusText, headers });
}
function isCacheable(response) {
// Only complete, same-origin, successful responses. This also rejects
// opaque responses (padded heavily in quota) and opaqueredirects.
if (!response || response.status !== 200 || response.type !== "basic") return false;
if (response.redirected) return false;
const cc = (response.headers.get("Cache-Control") || "").toLowerCase();
if (/(^|[,\s])(no-store|private)\b/.test(cc)) return false; // server said personal
const vary = (response.headers.get("Vary") || "").toLowerCase();
if (vary.includes("*") || vary.includes("cookie") || vary.includes("authorization")) {
return false; // personalized: Cache Storage can't tell users apart
}
return true;
}
async function putAndTrim(cache, request, response, cacheName) {
try {
await cache.put(request, response);
const max = LIMITS[cacheName];
if (!max) return;
// keys() lists entries in insertion order, and put() re-inserts an
// existing URL at the end, so this evicts the least recently stored.
const keys = await cache.keys();
for (const k of keys.slice(0, Math.max(0, keys.length - max))) await cache.delete(k);
} catch (err) {
if (err && err.name === "QuotaExceededError") {
// Images are disposable; free space for pages and assets.
await caches.delete(CACHE.images);
}
}
}
async function offlineResponse() {
const cached = await caches.match(OFFLINE_URL, { cacheName: CACHE.offline });
// Keep offline.html's own headers (including its CSP) when we have it.
const headers = new Headers(cached ? cached.headers : { "Content-Type": "text/html; charset=utf-8" });
headers.set("Cache-Control", "no-store");
const body = cached
? cached.body
: "<!doctype html><meta charset=utf-8><title>Offline</title><p>You're offline.</p>";
return new Response(body, { status: 503, statusText: "Offline", headers });
}
// Same routes as the vanilla worker, using Workbox modules. The build
// injects self.__WB_MANIFEST with offline.html and its revision.
import { precacheAndRoute, matchPrecache, cleanupOutdatedCaches } from "workbox-precaching";
import { registerRoute, setCatchHandler } from "workbox-routing";
import { CacheFirst, NetworkFirst, NetworkOnly, StaleWhileRevalidate } from "workbox-strategies";
import { ExpirationPlugin } from "workbox-expiration";
import * as navigationPreload from "workbox-navigation-preload";
const CACHEABLE_PAGES = [/^\/$/, /^\/blog(\/|$)/, /^\/products\/[^/]+$/, /^\/help(\/|$)/];
// Refuse anything the server marked as personal. Without a plugin that
// implements cacheWillUpdate, CacheFirst caches only status 200, while
// NetworkFirst and StaleWhileRevalidate also cache opaque (status 0)
// responses; neither default looks at Cache-Control or Vary.
const publicOnly = {
cacheWillUpdate: async ({ response }) => {
const cc = (response.headers.get("Cache-Control") || "").toLowerCase();
const vary = (response.headers.get("Vary") || "").toLowerCase();
const personal = /no-store|private/.test(cc) || /cookie|authorization|\*/.test(vary);
return response.status === 200 && response.type === "basic" && !personal ? response : null;
},
};
// Same caveat as the vanilla version: skipWaiting() is safe only while old
// pages can't request assets the new precache removed. Keep previous
// deploys' hashed assets on the server, or switch to a prompt-to-update flow.
self.skipWaiting();
navigationPreload.enable();
cleanupOutdatedCaches();
precacheAndRoute(self.__WB_MANIFEST); // contains /offline.html
registerRoute(
({ url, request }) =>
url.origin === self.location.origin &&
/^\/assets\/.+\.[0-9a-f]{8,}\./i.test(url.pathname) &&
request.destination !== "document",
new CacheFirst({
cacheName: "example-assets",
plugins: [publicOnly, new ExpirationPlugin({ maxEntries: 300 })],
}),
);
registerRoute(
({ url, request }) => url.origin === self.location.origin && request.destination === "image",
new StaleWhileRevalidate({
cacheName: "example-images",
plugins: [publicOnly, new ExpirationPlugin({ maxEntries: 120, purgeOnQuotaError: true })],
}),
);
registerRoute(
({ url, request }) =>
request.mode === "navigate" &&
url.origin === self.location.origin &&
CACHEABLE_PAGES.some((re) => re.test(url.pathname)),
new NetworkFirst({
cacheName: "example-pages-v2",
networkTimeoutSeconds: 4,
plugins: [publicOnly, new ExpirationPlugin({ maxEntries: 50 })],
}),
);
// All other navigations: network only, offline page on failure.
registerRoute(({ request }) => request.mode === "navigate", new NetworkOnly());
setCatchHandler(async ({ request }) => {
if (request.destination === "document") {
const offline = await matchPrecache("/offline.html");
if (offline) return offline;
}
return Response.error();
});
Workbox strategies use event.preloadResponse automatically when navigation preload is enabled. Unlike the vanilla version, NetworkFirst doesn't strip tracking parameters from the cache key; add a cacheKeyWillBeUsed plugin if you need that. Advanced Workbox shows how.
Both versions call skipWaiting() unconditionally. That is safe only when pages loaded by the old worker can't request assets the new version removed from its caches; keeping previous deploys' hashed assets on the server (see the CDN section below) is what makes it safe here. The Service Worker Lifecycle and Pitfalls & Anti-Patterns pages explain the failure mode and when to prompt instead.
Two behaviors of this worker are deliberate and worth understanding:
- The timeout only picks the cache when there is a cached copy. A first visit to a page on a slow network keeps waiting for the network, as it would without a worker. Serving the offline page after four seconds would be worse than the browser's behavior.
- Cached HTML references assets from its own deploy. If a user loads a cached page from last week, it requests last week's hashed files. They're usually in the assets cache, but not always, so your server must keep previous deploys' assets available (see the CDN section below).
SPAs: caching the shell, not the routes¶
In a single-page app every navigation returns the same index.html. Instead of caching pages by URL, cache the shell once and answer any failed navigation within the app's routes with it. Replace handleNavigation in the vanilla worker with this version:
const SHELL_URL = "/index.html";
// Navigations the SPA must never answer: server routes, auth flows and
// anything that looks like a file.
const NOT_APP_ROUTE = [/^\/api\//, /^\/auth\//, /^\/oauth\//, /\/[^/?]+\.[a-z0-9]+$/i];
async function handleNavigation(event, url) {
const network = (async () => (await event.preloadResponse) || fetch(event.request))();
const isAppRoute =
url.origin === self.location.origin && !NOT_APP_ROUTE.some((re) => re.test(url.pathname));
try {
const response = await network;
// Refresh the stored shell from any successful app-route navigation.
// The SPA's server rewrite returns index.html for every route.
if (isAppRoute && isCacheable(response)) {
const copy = response.clone();
event.waitUntil(caches.open(CACHE.pages).then((c) => c.put(SHELL_URL, copy)));
}
return response;
} catch {
if (isAppRoute) {
const shell = await caches.match(SHELL_URL, { cacheName: CACHE.pages });
if (shell) {
return new Response(shell.body, { status: 200, headers: shell.headers });
}
}
return offlineResponse();
}
}
This keeps the network as the source of truth for the shell (so deploys are picked up immediately) while making the app launch offline. Moving to a precached, cache-first shell is faster but changes your update model: users run the previous shell until the new worker activates. Do that as a separate, deliberate step with an update prompt; App Shell Model and Updating Service Workers cover it.
SPAs also have to handle chunk-load failures. A page that was loaded before a deploy lazily imports a chunk whose hashed file name no longer exists on the server. Keeping old assets deployed prevents most of these; this guard recovers from the rest by reloading once:
// Wrap dynamic imports: on a chunk-load failure, reload once to pick up the
// new deploy. The timestamp guard prevents reload loops when the failure is
// really a network problem.
const KEY = "chunk-reload-at";
export async function lazyImport(loader) {
try {
return await loader();
} catch (error) {
let last = 0;
try {
last = Number(sessionStorage.getItem(KEY) || 0);
} catch {}
if (navigator.onLine && Date.now() - last > 10_000) {
try {
sessionStorage.setItem(KEY, String(Date.now()));
} catch {}
location.reload();
return new Promise(() => {}); // never settles; the page is reloading
}
throw error; // offline or reloaded recently: let the UI show an error
}
}
// Usage: const Settings = await lazyImport(() => import("./settings.js"));
Vite also dispatches a vite:preloadError event on window when a preload fails, which you can handle the same way; see Framework Integrations for per-framework hooks.
When to cache API responses¶
Step 5d is where a migration can quietly turn into an offline-first rewrite. Cache an API response in the worker only if all of these hold:
- The response is the same for every user (reference data, catalogs, public content), or you partition caches per user and clear them on sign-out.
- Showing a stale copy is acceptable and the UI can say so ("Prices as of 10:42"). Offline UX & Fallbacks has patterns for freshness indicators.
- The endpoint is
GETand idempotent. Never cache or replay writes in the worker without an idempotency design.
User data and writes belong in IndexedDB, managed by the app, with explicit sync. That's an architecture change, not a caching tweak: plan it with Offline-First Data & Sync and IndexedDB.
Step 6: Authentication and personalized pages¶
Personalized content is where migrations cause real incidents: a cached page showing one user's name, cart or account data to the next person on a shared computer, or a cached "logged in" page shown after sign-out. The rules below prevent that.
Rule 1: the server decides what is personal¶
The worker can't reliably detect personalization. It never sees cookies, and HTML looks the same either way. Make the server say it, with headers it should already send for CDNs:
| Response | Headers | Worker behavior (with isCacheable() above) |
|---|---|---|
| Public page, anonymous render | Cache-Control: public, max-age=0, must-revalidate | Cacheable if on the allowlist |
| Public page rendered for a signed-in user (name in header, cart count) | Cache-Control: private, no-cache | Not cached |
| Account, cart, checkout, dashboards | Cache-Control: private, no-store | Not cached |
| Auth endpoints and redirects | Cache-Control: no-store | Not cached; redirects are opaqueredirect and never cached |
The second row is the one teams miss. Server-rendered sites often personalize the header of every page ("Hi, Sam", a cart badge). Either move that personalization to client-side code that calls an API (then the HTML is truly public and cacheable), or mark signed-in renders as private so the worker skips them. The allowlist in CACHEABLE_PAGES is a second layer of defense, not the first.
Rule 2: Vary: Cookie does not protect Cache Storage¶
Vary: Cookie tells HTTP caches to key responses by cookie value. It doesn't work that way in Cache Storage. Cookie is added to requests in the network layer, after the worker sees them, so it is absent from the Request objects that cache.put() stores and cache.match() compares. The comparison is "absent" against "absent," which matches for every user. The isCacheable() function above treats Vary: Cookie as "do not cache" for that reason. Cache Storage API explains Vary matching in detail.
Rule 3: never intercept the auth flow beyond the fallback¶
Sign-in, sign-out, OAuth callbacks and SAML endpoints must reach the server untouched:
POSTnavigations (form sign-in, SAML POST bindings) are skipped by therequest.method !== "GET"check.GETcallbacks such as/oauth/callback?code=...aren't on the page allowlist, so they only get the offline fallback on network failure. Keep them off any "cache everything" route you add later.- Redirect responses from these endpoints arrive in the worker as
opaqueredirectresponses (navigations use redirect modemanual). Passing them through, as the worker does, is correct. Never cache them.
Rule 4: clean up on sign-out¶
Anything cached while a user was signed in should disappear when they sign out. There are two approaches: a targeted cleanup from the page, or the Clear-Site-Data header on the sign-out response.
// Removes per-user data from caches and IndexedDB, then signs out.
// Public caches (assets, images) are kept so the next page load is fast.
const USER_CACHES = [/^example-pages-/, /^example-api-user-/];
export async function signOut() {
try {
if ("caches" in self) {
const names = await caches.keys();
await Promise.all(
names.filter((n) => USER_CACHES.some((re) => re.test(n))).map((n) => caches.delete(n)),
);
}
// Close open connections first or deleteDatabase() stays blocked.
await new Promise((resolve) => {
const req = indexedDB.deleteDatabase("example-user-data");
req.onsuccess = req.onerror = req.onblocked = () => resolve();
});
} catch (err) {
console.warn("Local cleanup incomplete", err); // still sign out
}
// A top-level form POST: not intercepted by the worker, so the server's
// response (and any Clear-Site-Data header) reaches the browser directly.
const form = document.createElement("form");
form.method = "POST";
form.action = "/logout";
document.body.append(form);
form.submit();
}
HTTP/1.1 303 See Other
Location: /
Cache-Control: no-store
Clear-Site-Data: "cache", "cookies", "storage"
"storage" removes Cache Storage, IndexedDB, localStorage and unregisters the service worker, which is the most thorough option for shared devices. "cookies" clears cookies for the whole registrable domain, so it also signs the user out of other subdomains. The worker reinstalls on the next visit. The header is ignored on responses served by a service worker, which is another reason the sign-out request must not be intercepted. MDN lists partial support for some directives, so treat it as a complement to targeted cleanup rather than a replacement. Updating Service Workers covers its semantics and support.
Offline, a signed-out state is also a special case: if the session expired while the user was offline, a cached public page may still render, but any action that needs the session must fail gracefully and ask the user to sign in when back online. Offline UX & Fallbacks covers session expiry handling, and Service Worker Security covers cache poisoning and scope risks.
Step 7: CDN and server configuration¶
Most migration incidents that aren't caused by the worker's code are caused by the CDN or the server answering the worker's own URLs incorrectly. Configure these paths explicitly:
| Path | Cache-Control | Other requirements |
|---|---|---|
/sw.js | no-cache (or max-age=0, must-revalidate) | Content-Type: text/javascript; never rewritten to index.html; short or zero edge TTL, purged on deploy |
/manifest.webmanifest | public, max-age=3600 or shorter | Content-Type: application/manifest+json; never rewritten |
/offline.html | no-cache | Fetched by the worker at install with cache: "reload" |
/pwa-config.json | no-store | Never cached by browser, CDN or worker |
/assets/*.[hash].* | public, max-age=31536000, immutable | Keep previous deploys' files for at least as long as cached HTML may reference them |
| HTML | Unchanged from today, private for personalized renders | – |
/api/* | Unchanged | private for user data |
Why /sw.js needs so much care: by default (updateViaCache: "imports"), the browser bypasses its own HTTP cache when it checks the worker script for updates. Your CDN doesn't know that. If the edge caches sw.js for a day, users get yesterday's worker for a day, including yesterday's bugs, and a kill switch you deploy during an incident doesn't reach them until the edge copy expires or you purge it.
Requests for the worker script carry a Service-Worker: script request header, which lets you write CDN or server rules specifically for update checks, for example to bypass the edge cache.
# Note: add_header in a location block replaces (not extends) headers set
# at server level, so shared headers live in a snippet included everywhere.
# snippets/security-headers.conf contains, for example:
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# add_header X-Content-Type-Options "nosniff" always;
server {
listen 80;
server_name www.example.com example.com;
return 301 https://www.example.com$request_uri;
}
server {
listen 443 ssl;
server_name www.example.com;
root /var/www/example/current;
include snippets/security-headers.conf;
location = /sw.js {
try_files $uri =404; # never fall through to index.html
include snippets/security-headers.conf;
add_header Cache-Control "no-cache" always;
types { text/javascript js; }
}
location = /manifest.webmanifest {
try_files $uri =404;
include snippets/security-headers.conf;
add_header Cache-Control "public, max-age=3600" always;
types { application/manifest+json webmanifest; }
}
location = /pwa-config.json {
try_files $uri =404;
include snippets/security-headers.conf;
add_header Cache-Control "no-store" always;
}
location = /offline.html {
include snippets/security-headers.conf;
add_header Cache-Control "no-cache" always;
}
location /assets/ {
try_files $uri =404; # missing chunks must 404, not return HTML
include snippets/security-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# SPA fallback (omit for server-rendered sites proxied to an app server).
location / {
try_files $uri $uri/ /index.html;
}
}
/sw.js
Cache-Control: no-cache
Content-Type: text/javascript; charset=utf-8
/manifest.webmanifest
Cache-Control: public, max-age=3600
Content-Type: application/manifest+json
/pwa-config.json
Cache-Control: no-store
/offline.html
Cache-Control: no-cache
/assets/*
Cache-Control: public, max-age=31536000, immutable
Netlify's SPA rule (/* /index.html 200 in _redirects) doesn't apply when a file exists at the path, so /sw.js is safe as long as the file is deployed. If it's missing, the rewrite returns index.html with status 200 and registration fails with a MIME type error. Don't add ! (force) to that rule.
{
"headers": [
{
"source": "/sw.js",
"headers": [
{ "key": "Cache-Control", "value": "no-cache" },
{ "key": "Content-Type", "value": "text/javascript; charset=utf-8" }
]
},
{
"source": "/manifest.webmanifest",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=3600" },
{ "key": "Content-Type", "value": "application/manifest+json" }
]
},
{
"source": "/pwa-config.json",
"headers": [{ "key": "Cache-Control", "value": "no-store" }]
},
{
"source": "/assets/(.*)",
"headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
}
]
}
Deploy order and asset retention¶
A deploy now has clients that run code from several releases at once: pages loaded before the deploy, cached pages from older releases, and workers that haven't updated yet. Order the deploy so every combination works:
- Upload new hashed assets alongside the old ones. Never delete the previous release's assets as part of a deploy.
- Deploy HTML (templates or
index.html) that references the new assets. - Deploy
/sw.jslast, then purge/sw.js,/manifest.webmanifestand HTML at the CDN. - Garbage-collect old assets on a schedule, keeping at least as many releases (or days) as your cached HTML can be old. With a 50-entry pages cache and weekly deploys, several weeks is a reasonable floor; measure 404s on
/assets/to tune it.
HTTP Caching & Service Workers explains how HTTP cache headers and the worker's caches interact.
Step 8: Roll out with feature flags and a kill switch¶
A staged rollout plan¶
Decide the stages and the abort criteria before phase 3 ships, and write them into the release ticket.
| Stage | Cohort | Minimum duration | Watch | Abort if |
|---|---|---|---|---|
| Internal | Staff (flag by cookie or IP on the config endpoint) | Several days of normal use | Console errors, offline page, installs on real devices | Any unexplained error |
| Canary | 1% of browsers | One week | Registration failures, navigation error rate, TTFB | Error or TTFB regression outside normal variance |
| Ramp | 10% → 25% → 50% | One week per step | All the above plus conversions and storage usage | Any significant regression in the treatment cohort |
| Full | 100% | – | Same dashboards, permanently | – |
Repeat the ramp for each new worker version that adds a route class (phase 4 steps). Worker versions update everyone who is already registered, so for version-level rollouts use the config to choose between worker behaviors, or keep the new route behind a flag the worker reads.
A flag inside the worker is useful for turning individual routes off without shipping a new worker. The worker can't read localStorage, so it fetches the same configuration file:
// Reads /pwa-config.json at most once per minute; defaults to "off" for
// every optional route if the config can't be loaded.
let flags = { cachePages: false, cacheImages: false };
let flagsFetchedAt = 0;
async function currentFlags() {
if (Date.now() - flagsFetchedAt < 60_000) return flags;
flagsFetchedAt = Date.now();
try {
const res = await fetch("/pwa-config.json", { cache: "no-store" });
if (res.ok) flags = { ...flags, ...(await res.json()).routes };
} catch {
// Offline: keep the last known flags for this worker lifetime.
}
return flags;
}
// In the fetch handler, instead of routing images directly:
// event.respondWith(
// currentFlags().then((f) =>
// f.cacheImages ? staleWhileRevalidate(event, CACHE.images) : fetch(event.request)),
// );
Globals reset whenever the browser stops the idle worker, so this caches the flags only briefly, which is what you want for a remote switch.
The kill switch¶
Keep a kill-switch worker in the repository from day one, test it in staging, and document how to deploy it in the incident runbook. It must be deployable at /sw.js, because browsers only check the registered URL.
// Kill switch: removes this origin's caches, unregisters the worker and
// reloads open windows so they come straight from the network.
const CACHE_PREFIX = "example-";
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const names = await caches.keys();
await Promise.all(names.filter((n) => n.startsWith(CACHE_PREFIX)).map((n) => caches.delete(n)));
await self.registration.unregister();
const windows = await self.clients.matchAll({ type: "window" });
await Promise.all(windows.map((c) => c.navigate(c.url).catch(() => {})));
})(),
);
});
// No fetch listener: requests bypass this worker while it finishes.
The procedure: set the page-side config to "kill" (for pages still served from the network), deploy sw-kill.js as /sw.js, purge the CDN, and keep the kill switch deployed until traffic from old workers has disappeared. Each browser picks it up on the next navigation to your site. Updating Service Workers covers variants (keeping push subscriptions alive), rollbacks and Clear-Site-Data.
sequenceDiagram
participant Ops
participant Config as /pwa-config.json
participant CDN
participant Browser
Ops->>Config: set serviceWorker = "kill"
Ops->>CDN: deploy sw-kill.js as /sw.js, purge /sw.js
Browser->>CDN: navigation (page from network or old cache)
Browser->>CDN: update check GET /sw.js (Service-Worker: script)
CDN-->>Browser: kill-switch bytes
Browser->>Browser: install, activate, delete caches, unregister
Browser->>CDN: windows reload from the network
Note over Browser: page-side script sees "kill" and keeps the worker unregistered Step 9: Measure impact¶
Compare cohorts, not controlled pages¶
The tempting analysis is "pages served by the worker are faster than pages that weren't." It's biased: a page can only be controlled on a repeat visit, and repeat visits are faster anyway (warm HTTP cache, warm DNS and TLS, engaged users on better devices). Compare the flag-on cohort to the flag-off cohort as whole groups, including their first visits. Use "controlled" only as a diagnostic dimension within the treatment cohort.
What to measure, by cohort:
| Metric | Source | What a successful migration shows |
|---|---|---|
| LCP, INP, CLS, FCP, TTFB at p75 | Field RUM with web-vitals | Equal or better; TTFB and LCP improve most for returning visitors once HTML or assets are cached |
| Navigation error rate | RUM plus server logs | Equal; any increase is a stop signal |
| Offline fallback impressions | Counter written by offline.html | Non-zero; each one is a session that previously saw a browser error |
| Worker registration failures | register-sw.js result | Near zero; spikes mean deploy or CDN problems |
| Worker startup, and handler plus network time | fetchStart - workerStart and responseStart - fetchStart in Navigation Timing | Startup small and stable across releases; large or growing values mean the worker is on the critical path: enable navigation preload, add static routes |
| Storage usage | navigator.storage.estimate() | Stable, well below quota |
| Installs and standalone launches | appinstalled, display-mode, start_url parameter | Growing once you add install UI |
| Business metrics | Your analytics | Return visits and conversion equal or better |
A RUM snippet for migrations¶
The script below reports Core Web Vitals with the migration dimensions attached. It uses the web-vitals library.
import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals";
const ENDPOINT = "/analytics/rum";
function navigationDetails() {
const nav = performance.getEntriesByType("navigation")[0];
if (!nav) return {};
const swInvolved = nav.workerStart > 0;
return {
// workerStart is taken just before the worker is started (or, if it is
// already running, before the fetch event is dispatched). The gap to
// fetchStart approximates boot plus dispatch; time spent inside your
// fetch handler shows up later, between fetchStart and responseStart.
swOverheadMs: swInvolved ? Math.round(nav.fetchStart - nav.workerStart) : null,
handlerAndNetworkMs: swInvolved ? Math.round(nav.responseStart - nav.fetchStart) : null,
swInvolved,
// transferSize 0 with a non-zero decodedBodySize suggests the document
// came from a cache (HTTP cache or the worker) rather than the network.
transferSize: nav.transferSize,
// deliveryType is Chromium-only ("cache", "navigational-prefetch", "").
deliveryType: nav.deliveryType ?? null,
navType: nav.type,
};
}
function context() {
let offlineViews = 0;
try {
offlineViews = Number(localStorage.getItem("offline-fallback-views") || 0);
if (offlineViews) localStorage.removeItem("offline-fallback-views");
} catch {}
const rollout = window.__swRollout ?? {};
return {
cohort: rollout.state === "registered" ? "sw-on" : rollout.state ?? "unknown",
bucket: rollout.bucket ?? null,
controlled: !!navigator.serviceWorker?.controller,
displayMode: ["standalone", "minimal-ui", "fullscreen", "window-controls-overlay"].find(
(m) => matchMedia(`(display-mode: ${m})`).matches,
) ?? "browser",
fromPwaStartUrl: new URLSearchParams(location.search).get("source") === "pwa",
offlineFallbackViews: offlineViews,
...navigationDetails(),
};
}
const queue = [];
let ctx;
// register-sw.js runs after the load event, but FCP and TTFB are reported
// before it. Build the context lazily at flush time (the page is being
// hidden, so registration has almost always settled), and only once,
// because context() consumes the offline-fallback counter.
function enqueue(metric) {
queue.push({
name: metric.name,
value: Math.round(metric.name === "CLS" ? metric.value * 1000 : metric.value),
rating: metric.rating,
navigationType: metric.navigationType,
});
}
function flush() {
if (!queue.length) return;
ctx ??= context();
const body = JSON.stringify({ page: location.pathname, ctx, metrics: queue.splice(0) });
if (!navigator.sendBeacon?.(ENDPOINT, body)) {
fetch(ENDPOINT, { method: "POST", body, keepalive: true }).catch(() => {});
}
}
onCLS(enqueue);
onFCP(enqueue);
onINP(enqueue);
onLCP(enqueue);
onTTFB(enqueue);
// visibilitychange to hidden is the last reliable moment to send data,
// including on mobile where unload events don't fire.
addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flush();
});
Report worker-side errors too. Errors thrown in the worker never reach the page's error handlers:
// Rate-limited error reporting from the worker. Uses a plain fetch: there is
// no sendBeacon in service workers.
let reported = 0;
function reportError(kind, error) {
if (reported++ > 5) return; // at most a few per worker lifetime
const body = JSON.stringify({
kind,
message: String(error?.message ?? error),
stack: String(error?.stack ?? "").slice(0, 2000),
version: VERSION,
});
fetch("/analytics/sw-error", { method: "POST", body, headers: { "Content-Type": "application/json" } })
.catch(() => {});
}
self.addEventListener("error", (e) => reportError("error", e.error ?? e.message));
self.addEventListener("unhandledrejection", (e) => reportError("unhandledrejection", e.reason));
Analytics for PWAs covers offline analytics queues, install attribution and display-mode reporting in more depth.
Common migration issues¶
| Symptom | Likely cause | Fix |
|---|---|---|
register() fails with a MIME type SecurityError | /sw.js answered with index.html by an SPA rewrite, or served as text/plain | Exact-match route for /sw.js, try_files $uri =404, correct Content-Type |
| Users see an old version long after a deploy | CDN edge caches /sw.js or HTML; or the new worker is waiting | Short edge TTL and purge on deploy; choose an update pattern (Updating) |
| Navigation fails with "a redirected response was used for a request whose redirect mode is not follow" | A cached response with redirected === true returned for a navigation | Don't cache redirected responses; re-wrap cached responses in a new Response |
| Signed-in user sees another user's name, or "logged in" UI after sign-out | Personalized HTML cached under a shared key | Server sends private for personalized renders; allowlist pages; clean caches on sign-out |
| Push notifications from an existing vendor stop | Your worker replaced the vendor's registration on scope / | importScripts() the vendor script into your worker, or separate scopes |
| Offline page shown when the server returns an error | A catch that also handles HTTP errors, or response.ok checks treated as failures | Fall back only when fetch() rejects; pass 4xx and 5xx through |
| Chunk-load errors after deploys | Old HTML (open tab or cached page) requests deleted assets | Keep old assets; reload-once guard; update prompt |
| Installed app shows a login page or a parse error for the manifest | Manifest behind cookie auth, fetched without credentials | Serve it publicly, or add crossorigin="use-credentials" |
Storage grows quickly, QuotaExceededError | Caching opaque cross-origin responses (padded heavily in Chromium quota) or unbounded caches | Cache same-origin or CORS responses only; cap entries; see Storage Quotas |
| Video playback breaks or seeking fails | Worker answering Range requests with full cached responses | Skip requests with a Range header, or implement range support (Advanced Techniques) |
| TTFB got worse for everyone | Fetch handler on every request without navigation preload; worker boot on the critical path | Enable navigation preload; static routes for requests you don't handle |
| Kill switch doesn't reach users | Deployed at a new URL, or CDN still serves the old /sw.js | Same URL, purge, no-cache |
| Analytics double-counts or misses offline page views | Offline page served at the requested URL with its own analytics | Mark fallback views (status 503, counter) and report them separately |
| Forms lose data when submitted offline | POST navigations aren't handled (by design) | Intercept submission in JavaScript and queue it (Background Sync, Offline UX) |
Pitfalls & Anti-Patterns goes deeper into worker-specific mistakes, and Browser DevTools shows how to inspect registrations, caches and update state while you debug.
Migration checklist¶
Audit
- URL classes inventoried with a caching decision for each
- Existing service worker registrations found (including third-party vendors) and a plan for each
- Header audit script run against production; issues fixed
- Baseline field metrics and error rates recorded
HTTPS and headers
- Every HTTP URL redirects to HTTPS in one hop; HSTS enabled
- No mixed content; one canonical host
- CSP allows
worker-src 'self'andmanifest-src 'self'
Manifest
-
id,start_url,scope,display,name,short_nameset deliberately -
anyandmaskableicons at 192 and 512 px;apple-touch-iconfor iOS - Manifest served as
application/manifest+json, reachable without credentials - Standalone mode tested: back navigation, external links, sign-in, safe areas
First worker
- Navigation-only fallback worker at
/sw.js, navigation preload enabled - Self-contained
offline.html, tested with DevTools offline mode and on real devices - Registration behind a remote flag with stable rollout buckets
- Kill-switch worker in the repository and tested in staging
Caching
- Assets content-hashed; old assets retained across deploys
- Route classes added one per release: assets, images, public HTML, APIs
-
isCacheable()rejectsprivate,no-store,Vary: Cookie, opaque and redirected responses - Cache sizes capped; quota errors handled
Auth and personalization
- Personalized renders marked
privateby the server - Auth endpoints never cached;
POSTnavigations never intercepted - Sign-out removes per-user caches and data
CDN and deploy
-
/sw.js, manifest and config never rewritten toindex.html -
/sw.jsedge TTL short and purged on deploy - Deploy order: assets, HTML, worker, purge
Rollout and measurement
- Stages and abort criteria written down before the first stage
- RUM reports cohort, control state, display mode and offline fallback views
- Worker errors reported to the backend
- Dashboards compare cohorts, not controlled vs uncontrolled page views
When the migration is complete, run through the Production Checklist and review SEO for PWAs to confirm that crawlers still see the same content as before.
Further reading¶
On this site
- When to Build a PWA: decide how far up the capability ladder to go
- Updating Service Workers: what happens between deploys
- HTTP Caching & Service Workers
- Offline UX & Fallbacks: designing the offline experience beyond the fallback page
- Static Routing API: keeping the worker off the critical path
- Workbox Fundamentals: the same routes with less code
- Installability Criteria: what each browser requires to install
- Production Checklist
External references