Skip to content

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 with Cache-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: Cookie doesn'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:

DevTools console
// 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.

scripts/pwa-migration-audit.mjs
#!/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 301 or 308. Chained redirects (http://example.com → https://example.com → https://www.example.com) cost a round trip each and confuse start_url and scope if they point at the wrong host.
  • HSTS (Strict-Transport-Security: max-age=31536000; includeSubDomains) so browsers stop making the insecure request at all. Only add preload once 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 Secure and, for session cookies, HttpOnly and SameSite=Lax or stricter. The worker never sees Cookie headers, but it forwards requests that carry them.
  • One canonical host. A worker registered on https://www.example.com does nothing for https://example.com. Pick one host, redirect the other, and use it in start_url, scope and id.

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.

manifest.webmanifest
{
  "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:

  • id is the app's identity. If you omit it, browsers derive it from start_url, and changing start_url later (for example, to add a tracking parameter) would make browsers treat it as a different app. Set id explicitly from day one. App Identity & Updates explains the rules.
  • start_url with a query parameter such as ?source=pwa lets 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.
  • scope decides 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 on pay.example.com, help center on a SaaS domain), users will leave the app window when they follow those links.
  • Icons: provide any icons at 192 and 512 pixels and separate maskable icons 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:

partials/head-pwa.html
<!-- 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:

    standalone.css
    /* Show the in-app back button only when there is no browser UI. */
    .app-back-button { display: none; }
    @media (display-mode: standalone), (display-mode: fullscreen) {
      .app-back-button { display: inline-flex; }
    }
    
  • Links with target="_blank" and window.open() open outside the app window. That's usually right for external sites, wrong for your own pages. Remove _blank from 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, use env(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:

  1. 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.
  2. 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.
  3. 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.
  4. Skip non-GET navigations. Form POST navigations stay entirely with the browser, so nothing changes for checkout or login forms.
  5. Keep the offline page self-contained: inline CSS, no external scripts, no images that aren't also cached.
  6. Keep the worker's URL stable. /sw.js today 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

offline.html
<!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

sw.js (v1: 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 fetch handler 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 cached directly for a navigation if it might be a redirected response. offline.html fetched with cache.add() is fine unless your server redirects it (for example, adding a trailing slash or forcing a locale), in which case the stored response has redirected === true and browsers refuse it for navigations. Re-wrapping in a new Response, 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: true header. 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.

register-sw.js
// 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.

sw.js (v2: offline fallback + incremental caching)
// 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 });
}
src/sw.js (built with workbox-build injectManifest)
// 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:

sw.js (SPA navigation handler)
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:

src/lazy-import.js
// 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 GET and 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.

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:

  • POST navigations (form sign-in, SAML POST bindings) are skipped by the request.method !== "GET" check.
  • GET callbacks 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 opaqueredirect responses (navigations use redirect mode manual). 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.

sign-out.js
// 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();
}
Sign-out response
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.

/etc/nginx/conf.d/example.conf
# 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;
    }
}
public/_headers
/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.

vercel.json
{
  "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:

  1. Upload new hashed assets alongside the old ones. Never delete the previous release's assets as part of a deploy.
  2. Deploy HTML (templates or index.html) that references the new assets.
  3. Deploy /sw.js last, then purge /sw.js, /manifest.webmanifest and HTML at the CDN.
  4. 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:

sw.js (route flags, excerpt)
// 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.

sw-kill.js (deploy as /sw.js during an incident)
// 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.

src/rum.js
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:

sw.js (error reporting, excerpt)
// 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' and manifest-src 'self'

Manifest

  • id, start_url, scope, display, name, short_name set deliberately
  • any and maskable icons at 192 and 512 px; apple-touch-icon for 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() rejects private, no-store, Vary: Cookie, opaque and redirected responses
  • Cache sizes capped; quota errors handled

Auth and personalization

  • Personalized renders marked private by the server
  • Auth endpoints never cached; POST navigations never intercepted
  • Sign-out removes per-user caches and data

CDN and deploy

  • /sw.js, manifest and config never rewritten to index.html
  • /sw.js edge 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

External references