Skip to content

SPA vs MPA PWAs

A single-page application (SPA) loads one HTML document and renders every later screen with JavaScript, while a multi-page application (MPA) loads a new HTML document from the server (or from the service worker) on every navigation. For a Progressive Web App the choice decides what your service worker does with navigation requests, which pages open offline, how much memory a long-running installed window accumulates, and which browser features (the back/forward cache, speculative prerendering, cross-document View Transitions) work for you or against you. In 2026 the old rule "PWA means SPA" no longer holds: an MPA with a per-page caching worker, cross-document transitions and prerendering feels app-like in Chromium and Safari, and many production PWAs are hybrids of both.

Key takeaways

  • The service worker strategy must match the routing model. SPAs answer every in-scope navigation with a precached shell (navigation fallback); MPAs cache each page under its own URL, usually network-first with navigation preload, and fall back to the cached page and then an offline page.
  • An SPA shell makes every route open offline, but only if the route's data is also local. An MPA only opens offline the pages that were visited or precached, so it needs an offline page that lists what is available.
  • Cross-document View Transitions (Chrome 126, Safari 18.2), Speculation Rules prefetch and prerender (Chromium; prefetch only, behind a flag, in Safari 26.2), and the back/forward cache give MPAs instant, animated navigations without a client router.
  • The bfcache restores MPA pages with no request at all. Service worker activity (clients.claim(), postMessage() to the client, activation of a new version, unregistration) evicts pages from it, so design updates with that in mind.
  • Since Chrome 138, a speculation-rules prefetch of a service-worker-controlled URL goes through your fetch handler and is kept in the prefetch cache instead of being cancelled.
  • SPAs keep one JavaScript heap alive for the whole session, which in an installed window can mean days. Leaks that a document reload would hide become visible. MPAs start clean on every navigation.
  • Most real PWAs end up hybrid: content sections as MPA pages, an application section as an SPA, one service worker routing each part of the URL space differently.

What SPA and MPA mean for a PWA

The terms describe what happens when the user follows a link inside your app, not which framework you use. Next.js, Nuxt, SvelteKit and Astro can each produce either shape (or both), and a plain server-rendered site can be an SPA if it intercepts clicks.

Question Single-page application Multi-page application
Documents loaded per session One (plus reloads) One per navigation
Who renders the next screen Client-side router in the existing document Server, build step or service worker, then the browser's HTML parser
JavaScript heap Survives every route change Discarded on every navigation (or frozen in the bfcache)
Navigation requests seen by the service worker One per launch One per click
Default service worker navigation strategy Serve the precached shell for every URL Cache and serve each URL's own document
What makes transitions animated document.startViewTransition() or framework animation @view-transition { navigation: auto; }
What makes the next screen instant Code splitting, data prefetch, local data Speculation Rules, bfcache, cached pages
URL-to-content mapping Client route table Server routes or files on disk
Performance metrics in the field One hard navigation per session plus soft navigations One hard navigation per page

Two neighbouring concepts are worth separating from the SPA/MPA axis:

  • Rendering strategy (CSR, SSR, SSG, ISR, streaming, islands) decides where HTML is produced. An SSR framework that hydrates and then takes over routing is an SPA after the first load. The Architecture overview maps each rendering strategy to a service worker navigation strategy.
  • The app shell model is the caching pattern that makes an SPA work offline: precache the shell, serve it for every navigation. It is covered in full in App Shell Model. This page compares it with the MPA alternative rather than repeating it.

How navigation works in each model

In an MPA, every same-origin link click is a real navigation. The browser creates a navigation request (mode: "navigate", destination: "document", redirect: "manual"), dispatches it to the service worker that controls the URL's scope, and replaces the current document with whatever the worker returns.

sequenceDiagram
    participant U as User
    participant P1 as Page A (document)
    participant B as Browser
    participant SW as Service worker
    participant N as Network
    U->>P1: click link to /b
    P1->>B: navigate to /b (navigate event, page A stays visible)
    B->>N: navigation preload request (if enabled)
    B->>SW: fetch event (mode navigate)
    SW->>SW: route: network-first for pages
    N-->>SW: HTML for /b (via preloadResponse)
    SW-->>B: Response (streamed)
    Note over P1: response committed, then pageswap and pagehide fire
    Note over B: page A frozen into bfcache if eligible
    B->>B: parse page B, pagereveal before first frame
    B-->>U: Page B rendered

The old page stays visible and interactive until the response for /b arrives and the navigation commits; only then does the browser fire pageswap and pagehide on page A. A slow worker or server therefore shows up as the old page lingering, not as a blank screen, which is also why cross-document View Transitions have a time budget (see below).

Everything the MPA page needs (CSS, scripts, fonts) is requested again, and the service worker answers those requests too, usually from Cache Storage. The HTTP cache and the worker make the repeat cost small, but the browser still re-parses the HTML, recalculates styles and re-executes scripts on every page.

A route change in an SPA

In an SPA, the client router intercepts the click, updates the URL with the History API or the Navigation API, fetches data (JSON, usually through the worker) and re-renders part of the existing DOM. The worker sees no navigation request, only the data and lazy-loaded code chunks:

sequenceDiagram
    participant U as User
    participant R as Router (in page)
    participant SW as Service worker
    participant N as Network
    U->>R: click link to /b
    R->>R: preventDefault or navigation.intercept()
    R->>R: history.pushState or Navigation API commit
    R->>SW: fetch /api/b.json and /assets/route-b.js
    SW->>N: network-first for API, cache-first for chunk
    N-->>SW: data
    SW-->>R: responses
    R->>R: render route B into existing DOM
    R-->>U: Route B (soft navigation)

What the service worker sees in each model

Request type SPA, per session MPA, per page view
Navigation (mode: "navigate") 1 on launch, plus reloads and deep links opened from outside 1 per page
Subresources (CSS, JS) Initial bundle plus lazy chunks as routes are first visited All render-blocking resources of every page, typically cache hits
Data (fetch() / XHR) Every route's API calls Only what islands or client code request
Worker start-ups Usually one per session; more if the worker idles out between API calls Potentially one per navigation, if the worker was terminated while the user read the previous page

The last row matters for MPAs. Browsers terminate idle service workers (Chromium after about 30 seconds without events), so a user who reads an article for two minutes and then clicks a link pays the worker start-up cost on the next navigation. Navigation Preload hides that cost by starting the network request in parallel with worker start-up, and the Static Routing API can skip the worker entirely for requests that do not need it. SPAs pay start-up mostly on launch, where the precached shell masks it.

Client-side routing in an SPA PWA

History API or Navigation API

SPAs historically routed with history.pushState(), a global click listener on <a> elements and a popstate listener for Back and Forward. That approach has well-known gaps: popstate does not fire for pushState(), form submissions and programmatic location assignments are not intercepted, and scroll and focus restoration are left entirely to you.

The Navigation API (window.navigation) replaces it with a single navigate event that fires for every navigation of the document's own frame, whatever started it: link clicks, form submissions, location.assign(), history.pushState(), Back, Forward and navigation.navigate(). The router decides per event whether to intercept it (turning it into a same-document navigation) or let it proceed as a normal cross-document navigation. It shipped in Chrome and Edge 102 and completed cross-engine support with Safari 26.2 and Firefox 147 (late 2025 and early 2026), which makes it Baseline Newly available.

The rules that decide whether your router may intercept a navigation are exposed on the event itself:

NavigateEvent property Why the router checks it
canIntercept false for cross-origin URLs and some cross-document traversals. Calling intercept() then throws a SecurityError.
hashChange true for fragment-only changes; let the browser scroll to the anchor.
downloadRequest Non-null when the link has a download attribute; never intercept.
formData Non-null for POST form submissions; decide whether the SPA handles them or the server does.
navigationType "push", "replace", "reload" or "traverse"; choose transition direction and scroll behavior.
destination.url The target URL; match it against your route table.
signal An AbortSignal that fires when the user starts another navigation or presses Stop; pass it to your fetch() calls.
userInitiated Whether the user clicked a link or submitted a form rather than script navigating.
sourceElement The link or submitter that started the navigation (Chrome 135, Safari 26.2, Firefox 147).

A minimal but complete router that hands unknown and server-only URLs back to the browser (and therefore to the service worker as real navigations) looks like this:

router.js
// Routes this SPA renders itself. Anything else becomes a real navigation,
// which the service worker handles with its own rules.
const routes = [
  { pattern: new URLPattern({ pathname: "/app/" }), load: () => import("./views/home.js") },
  { pattern: new URLPattern({ pathname: "/app/notes/:id" }), load: () => import("./views/note.js") },
  { pattern: new URLPattern({ pathname: "/app/settings" }), load: () => import("./views/settings.js") },
];

// Server-only paths inside the app's URL space: OAuth callbacks, exports, sign-out.
const serverOnly = [/^\/app\/auth\//, /^\/app\/export\//, /^\/app\/logout$/];

function match(url) {
  if (serverOnly.some((re) => re.test(url.pathname))) return null;
  for (const route of routes) {
    const result = route.pattern.exec(url.href); // pass a string, not the URL object
    if (result) return { route, params: result.pathname.groups };
  }
  return null;
}

async function render(url, signal) {
  const matched = match(url);
  if (!matched) throw new Error(`No client route for ${url.pathname}`);
  const view = await matched.route.load();
  // Views receive the signal so a superseded navigation stops its data fetches.
  const node = await view.render(matched.params, { signal });
  signal.throwIfAborted();
  document.querySelector("main").replaceChildren(node);
  document.title = view.title?.(matched.params) ?? document.title;
}

if ("navigation" in window) {
  navigation.addEventListener("navigate", (event) => {
    const url = new URL(event.destination.url);
    if (!event.canIntercept || event.hashChange || event.downloadRequest) return;
    if (event.formData) return; // Let POST forms reach the server (or the worker's outbox).
    if (url.origin !== location.origin || !match(url)) return; // Real navigation.

    event.intercept({
      // "after-transition" (the default) restores scroll on traverse and
      // scrolls to top or to the fragment on push/replace once the handler settles.
      scroll: "after-transition",
      focusReset: "after-transition",
      async handler() {
        await render(url, event.signal);
      },
    });
  });
} else {
  // History API fallback for browsers older than the versions listed above.
  document.addEventListener("click", (event) => {
    const link = event.target.closest("a[href]");
    if (!link || event.defaultPrevented || event.button !== 0) return;
    if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
    if (link.target && link.target !== "_self") return;
    if (link.hasAttribute("download")) return;
    const url = new URL(link.href);
    if (url.origin !== location.origin || !match(url)) return;
    if (url.pathname === location.pathname && url.search === location.search && url.hash) return;

    event.preventDefault();
    history.pushState(null, "", url);
    render(url, new AbortController().signal).catch(() => location.assign(url));
  });
  window.addEventListener("popstate", () => {
    render(new URL(location.href), new AbortController().signal).catch(() => location.reload());
  });
}

URLPattern is used for matching; it ships in Chrome and Edge 95, Safari 26 and Firefox 142, so production code that must support older engines should include the urlpattern-polyfill package or use regular expressions. The View Transitions page shows how to wrap render() in document.startViewTransition() for animated route changes (View Transitions).

Every SPA PWA has URLs inside its scope that are not client routes: OAuth and SAML callbacks, file exports, server-rendered admin pages, sign-out endpoints that clear HttpOnly cookies, and API URLs a user may open in a tab. They fail in two places if you forget them:

  1. The client router intercepts the click and renders a "not found" view. The router above returns null from match() for those paths, so the browser performs a real navigation.
  2. The service worker's navigation fallback answers the resulting real navigation with the shell, and the router again cannot render it. The worker needs the same denylist. Keep one list and generate both from it at build time, or the two drift apart.

Status codes in an SPA

Because the shell is served with 200 OK for every URL, an SPA cannot return a real 404 or 410 on its own. Search engines treat such pages as soft 404s. Server-render a proper error status for unknown URLs on the first (worker-less) visit, and inside the app render a "not found" view with <meta name="robots" content="noindex"> added client-side. SEO for PWAs covers status codes and indexing in depth.

The single most consequential difference between an SPA PWA and an MPA PWA is the service worker's fetch handler for request.mode === "navigate".

The SPA worker precaches index.html (or /app/shell.html) together with its hashed assets at install time and answers every in-scope navigation with that cached document. The client router then reads location and renders the right view.

Mechanically, three properties make this work and three cause most of its bugs:

  • The response URL is irrelevant. When the worker responds to a navigation for /app/notes/42 with the cached /app/index.html, the document's URL is still /app/notes/42: location and the router see the requested address. Relative URLs in the shell resolve against that address too, so assets/app.js would load /app/notes/assets/app.js. Use root-relative asset paths (/app/assets/app.js) or a <base href="/app/"> in the shell.
  • The shell is versioned by the worker. The cached shell only changes when a new worker installs and activates with a new precache. Users on an old shell with new API responses is the classic SPA update problem; Updating Service Workers covers reload prompts and version negotiation.
  • Navigation preload is wasted. Because the worker never uses the network response for navigations, enabling Navigation Preload doubles work: every launch downloads HTML that is thrown away. Disable it in SPA workers (and explicitly disable() it if a previous version enabled it, because the setting persists on the registration).
  • Denylist omissions send the shell to server-only URLs (see above). Workbox's NavigationRoute has allowlist and denylist options for this; its documentation states that the regular expressions are matched against the pathname plus search of the URL and that the denylist wins when both match.
  • File-like URLs. A user opening /app/report.pdf in a tab is a navigation. A common heuristic, used by Create React App's generated worker, excludes any URL whose last path segment contains a dot: /\/[^/?]+\.[^/]+$/.
  • Deep links before the worker exists. The first visit to /app/notes/42 has no worker, so the server must also return the shell (or a server-rendered page) for every client route. That is a hosting rewrite rule, not a worker concern, and forgetting it produces 404s on the first visit only, which is easy to miss in testing.

Precaching & Runtime Caching documents Workbox's allowlist and denylist semantics and the ways the fallback breaks in production.

Per-page caching: each URL has its own document (MPA)

An MPA worker cannot answer /products/42 with anything but /products/42, so it caches documents under their own URLs and chooses a strategy per section:

Section character Strategy Offline result
Personalized or fast-changing HTML (account, cart, feeds) Network-first with navigation preload and a timeout Last cached copy if safe to show, otherwise the offline page
Editorial content (articles, docs, product pages) Network-first, or stale-while-revalidate if one visit of staleness is acceptable Last cached copy
Small, finite set of pages (docs, handbook, schedule) Precache everything with a revisioned manifest Every page
Pages that must never be stored (checkout, sign-in, admin) Network-only (or a Static Routing network source) The offline page

Per-page caching has details that the SPA shell never runs into.

The cache key. Marketing links add query parameters (utm_source, gclid, fbclid) that produce the same document. Without normalization, each variant is a separate cache entry and an offline user who opens the bare URL gets a miss. Strip known tracking parameters before cache.put() and cache.match(), and consider ignoreSearch: true only for sections where the query string never changes the content. Fragments (#section) are never part of a request URL, so they need no handling.

Vary and synthesized keys. cache.match() honors the stored response's Vary header: for every header name it lists, the value on the stored request must equal the value on the lookup request, and a stored response with Vary: * never matches (unless you pass ignoreVary: true). If your server varies HTML on request headers that differ between a real navigation, a speculative prefetch and a fetch() from a "save for offline" button (Accept, Sec-Purpose, framework headers such as Next.js's RSC), the same page is stored and looked up under incompatible variants and hit rates become unpredictable. Using a normalized URL string as the key, so that both the stored and the lookup request are header-less synthesized requests, makes matching deterministic. The trade-off is that you take responsibility for never serving one variant or one user's page in place of another, which in practice means excluding personalized sections and clearing the pages cache at sign-out.

Opaque redirects. A navigation request has redirect mode "manual". When your worker calls fetch(event.request) and the server redirects, the worker receives a response with type: "opaqueredirect" and status: 0, passes it back to the browser, and the browser follows the redirect as a new navigation, which your worker then handles again. Never cache opaque redirects: they have no body and would replay the redirect offline with no destination.

Redirected responses cannot answer navigations. The Fetch standard turns a service worker response into a network error when the request's redirect mode is not "follow" and the response was produced by following redirects (its URL list has more than one entry, visible as response.redirected === true). That happens when you precache /offline with cache.add() and the server redirects it to /offline/, or when you fetch() a page by URL string (default redirect mode "follow") and store it. Serving such a response to a navigation fails with a console error along the lines of "a redirected response was used for a request whose redirect mode is not follow". The fix is to copy the response into a fresh Response, which resets the URL list:

sw-utils.js
// Rebuild a response so that `redirected` is false and it can answer navigations.
export async function cleanRedirect(response) {
  if (!response.redirected) return response;
  // A ReadableStream body keeps this streaming; fall back to a Blob for old engines.
  const body = "body" in Response.prototype ? response.body : await response.blob();
  return new Response(body, {
    status: response.status,
    statusText: response.statusText,
    headers: response.headers,
  });
}

Workbox applies the same fix internally with its copyResponse() helper when it precaches redirected URLs.

Only cache what is safe to replay. Check response.ok, response.type === "basic" (same-origin, not opaque), a text/html content type, and the absence of Cache-Control: no-store or private before storing a page. Pages with anti-CSRF tokens bound to a session are unsafe to replay after the session changes; either exclude them or render the token client-side from a cookie.

Timeouts that still refresh the cache. A network-first handler with a timeout should not discard the slow response when the timeout wins. Keep the network promise alive with event.waitUntil() and store the response when it finally arrives, so the next visit gets fresh content from the cache. The complete MPA worker later on this page does exactly that.

Because MPAs send one navigation per click to a worker that may be cold, the two worker-bypass features are more valuable for them than for SPAs:

  • Navigation preload (Chrome 59, Firefox 99, Safari 15.4) starts the HTML request while the worker boots. The worker reads it from event.preloadResponse. With preload, a network-first navigation handler costs roughly nothing over no worker at all; without it, a cold worker delays every MPA navigation by its start-up time.
  • Static Routing (InstallEvent.addRoutes(), Chrome 123 and Safari 27, not Firefox) lets the worker declare at install time that some navigations go straight to the network ("network" source), or race the network against the fetch handler ("race-network-and-fetch-handler"), without starting the worker first. For an MPA, declaring checkout, sign-in and other network-only sections as "network" removes the worker from those navigations completely. See Static Routing API for the full rule syntax.

Offline behavior compared

Scenario SPA with app shell MPA with per-page caching
Launch from the home screen offline Shell opens; start URL route renders if its data is local Start URL opens if it was cached (precache it)
Open a deep link offline (from a notification, shortcut or share) Shell opens; route renders if its data is local Opens only if that exact page was cached; otherwise the offline page
Navigate to a page never visited Renders with local data or an in-app empty state Offline page
Back button offline Client router re-renders from memory or local data bfcache restore (no request) or cached page
Submit a form offline App code writes to IndexedDB and queues a sync Plain form POST fails; needs a worker outbox or JavaScript to queue it
Data freshness indicator App decides per view Page shows whatever was cached; needs a "saved at" stamp injected by the worker or rendered in the page
Storage footprint Shell and assets plus structured data in IndexedDB One HTML document per cached page plus shared assets

The SPA's offline advantage is conditional: the shell only helps if the data for the route is local. A notes app that stores notes in IndexedDB opens any note offline; a dashboard that fetches every widget from the network opens to a screen of spinners. Offline-First Data & Sync covers the data half.

The MPA's offline weakness is structural: unvisited pages do not exist on the device. Three techniques reduce it:

  1. Precache the pages people need offline. The start URL, the offline page, key landing pages and, for documentation-style sites, everything.
  2. Save for offline. Let users mark pages (articles, tickets, recipes) for offline reading; the page posts a message to the worker or writes to Cache Storage directly. Offline UX & Fallbacks has the complete pattern.
  3. An offline page that lists what is available. The Cache API is exposed to pages, so the offline fallback can enumerate the pages cache and link to them.
offline.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>You are offline</title>
  <link rel="stylesheet" href="/assets/site.css">
</head>
<body>
  <main>
    <h1>You are offline</h1>
    <p>This page has not been saved on this device. These pages are available offline:</p>
    <ul id="available"></ul>
    <button type="button" id="retry">Try again</button>
  </main>
  <script type="module">
    // Must match the cache name used by the service worker for pages.
    const PAGES_CACHE = "pages-v1";
    const list = document.getElementById("available");

    async function listCachedPages() {
      if (!("caches" in window)) return;
      const cache = await caches.open(PAGES_CACHE);
      const requests = await cache.keys();
      const items = await Promise.all(
        requests.map(async (request) => {
          const response = await cache.match(request);
          // The worker stores the page title in a custom header when caching.
          const title = response?.headers.get("X-Page-Title") || new URL(request.url).pathname;
          return { url: request.url, title };
        }),
      );
      for (const { url, title } of items) {
        const li = document.createElement("li");
        const a = document.createElement("a");
        a.href = url;
        a.textContent = safeDecode(title); // textContent, never innerHTML
        li.append(a);
        list.append(li);
      }
      if (items.length === 0) list.innerHTML = "<li>No pages saved yet.</li>";
    }

    function safeDecode(value) {
      try {
        return decodeURIComponent(value);
      } catch {
        return value; // malformed escape sequence: show it raw
      }
    }

    document.getElementById("retry").addEventListener("click", () => location.reload());
    window.addEventListener("online", () => location.reload());
    listCachedPages().catch(() => {});
  </script>
</body>
</html>

Because the offline page is served as the response to a navigation for a different URL, location.reload() retries the original URL, which is exactly what the user wants when connectivity returns.

Making an MPA feel like an app

Three browser features close most of the experiential gap between an MPA and an SPA. None requires a client router, and all three work in installed PWAs because an installed window runs the same browser engine.

Cross-document View Transitions

A cross-document View Transition animates between the old and new document on a same-origin navigation. Both pages opt in with CSS:

site.css
@view-transition {
  navigation: auto;
}

/* Morph the header and the product image between list and detail pages. */
.site-header { view-transition-name: site-header; }
.product-hero { view-transition-name: product-hero; }

@media (prefers-reduced-motion: reduce) {
  @view-transition { navigation: none; }
}

The browser snapshots the old page when the navigation commits, renders the new page's first frame, and animates between them using the same pseudo-element tree as same-document transitions. The pageswap event (old page) and pagereveal event (new page) expose event.viewTransition so you can set transition types or dynamic names. Support: Chrome and Edge 126, Safari 18.2; not yet in Firefox, where the navigation simply happens without animation.

Two conditions make or break cross-document transitions in a PWA:

  • They never run for reloads, cross-origin navigations, cross-origin redirects, or navigations started from browser UI, which includes launching the installed app from its icon. Back and forward (including the Android back gesture) do animate.
  • They are skipped if the new page is not ready in time. Chrome's documentation gives four seconds from the start of the navigation, and network and server time count against it. A service worker that answers navigations from Cache Storage, or navigation preload for network-first pages, keeps you inside that budget on slow networks. Prerendering the target (next section) removes network time entirely.

The full API, including direction-aware transitions, render blocking with <link rel="expect"> and framework integration, is on View Transitions.

The back/forward cache

The back/forward cache (bfcache) keeps a page that the user navigated away from alive but frozen in memory, with its JavaScript heap, DOM and scroll position, and restores it instantly when the user goes Back or Forward. web.dev reports that, according to Chrome usage data, 1 in 10 navigations on desktop and 1 in 5 on mobile are back or forward navigations.

A bfcache restore makes no network request and dispatches no fetch event: the service worker, the HTTP cache and your server are not involved. For an MPA that is the fastest navigation possible. For an SPA, bfcache matters less, because Back within the app is handled by the router; it only applies when the user leaves the SPA document entirely (to an OAuth provider, a payment page, an external link) and returns.

The events you work with:

  • pagehide with event.persisted === true means the browser intends to put the page in the bfcache. It is not a guarantee.
  • pageshow with event.persisted === true means the page was restored from the bfcache. Refresh anything time-sensitive here.
  • freeze and resume (Chromium only) fire around the frozen state.
  • performance.getEntriesByType("navigation")[0].notRestoredReasons (Chromium 125, experimental) tells you why a history navigation was not served from the bfcache.

What evicts or blocks bfcache in a PWA

Among the user-agent-specific NotRestoredReasons values listed in the HTML standard (and documented on MDN), five come from service workers. Each evicts a page that is sitting in the bfcache:

Reason string Triggered when, while the page is in the bfcache
serviceworker-added The page starts being controlled by a service worker
serviceworker-claimed An active service worker calls clients.claim()
serviceworker-postmessage The active service worker posts a message to the page's client
serviceworker-version-activated A new service worker version is activated for the page
serviceworker-unregistered The page's service worker registration is unregistered

Evicting on those events is deliberate: a frozen page cannot process the message or adapt to the new worker, so restoring it would leave it inconsistent. The practical consequences:

  • A worker that calls skipWaiting() and clients.claim() on every deploy evicts every bfcached page of your origin. That is usually fine (the next Back becomes a normal navigation served from your cache), but it means bfcache hit rates drop right after deploys.
  • Broadcasting messages to all clients with clients.matchAll() and client.postMessage() evicts every bfcached client. Switching to a BroadcastChannel does not help, because a message delivered to a frozen page is also an eviction reason (broadcastchannel-message). Message only the clients that need it, for example those with client.visibilityState === "visible" (exposed on WindowClient), and let hidden pages catch up in pageshow.
  • Registering Background Sync, Periodic Background Sync or Background Fetch from a page makes it ineligible (background-work).

Other blockers that commonly affect PWAs:

  • unload handlers. Chrome and Firefox on desktop refuse to bfcache pages with unload listeners; Chrome on Android and Safari attempt to cache some of them anyway. Use pagehide instead, and consider Permissions-Policy: unload=() to catch third-party scripts.
  • Cache-Control: no-store on the document. Historically this blocked bfcache everywhere, and WebKit still refuses such HTTPS documents. Chrome completed a rollout in March and April 2025 that lets them enter the bfcache, but evicts them when cookies change and keeps them for at most 3 minutes instead of 10. Use no-cache for ordinary HTML and reserve no-store for genuinely sensitive pages.
  • Open connections at the moment of navigation: open IndexedDB connections in some engines (and, in every engine that reports it, a pending versionchange event, reason idbversionchangeevent), in-flight fetch() requests, WebRTC and WebSockets. web.dev notes that Chrome (from version 149) and Safari no longer block on open WebSockets, but other browsers do. Close these in pagehide and reopen them in pageshow.
  • window.opener references. Pages opened with target="_blank" and without rel="noopener" share a browsing context group with their opener. Modern browsers imply noopener for target="_blank", but window.open() without it does not.

Handling bfcache restores in an installed MPA

Installed PWAs stay open for a long time, so a restored page can be hours old. Refresh the state that matters when the page comes back, and check for a service worker update at the same time:

bfcache.js
// Restored pages keep their old DOM. Refresh anything that can be stale.
window.addEventListener("pageshow", async (event) => {
  if (!event.persisted) return;

  // 1. Session: if the user signed out in another page, don't show private data.
  const session = await fetch("/api/session", { cache: "no-store" }).catch(() => null);
  if (session && session.status === 401) {
    location.replace("/signin");
    return;
  }

  // 2. Small pieces of shared state (cart badge, unread count).
  document.dispatchEvent(new CustomEvent("app:refresh-state"));

  // 3. The page may have been frozen through a deploy. Ask for an update check;
  //    it is cheap because the browser revalidates the worker script only.
  const registration = await navigator.serviceWorker?.getRegistration();
  registration?.update().catch(() => {});
});

// Close resources that can block bfcache when leaving; reopen lazily on return.
let dbPromise;
export function getDb() {
  dbPromise ??= new Promise((resolve, reject) => {
    const request = indexedDB.open("app", 1);
    request.onupgradeneeded = () => request.result.createObjectStore("state");
    request.onsuccess = () => {
      const db = request.result;
      // Another tab upgrading the schema: close so neither page is blocked.
      db.onversionchange = () => {
        db.close();
        dbPromise = undefined;
      };
      resolve(db);
    };
    request.onerror = () => {
      dbPromise = undefined;
      reject(request.error);
    };
  });
  return dbPromise;
}

window.addEventListener("pagehide", (event) => {
  if (!event.persisted) return; // the page is being destroyed, nothing to restore
  dbPromise?.then((db) => db.close()).catch(() => {});
  dbPromise = undefined; // the next getDb() call after pageshow reopens it
});

// Report why history navigations missed the bfcache (Chromium only).
const [nav] = performance.getEntriesByType("navigation");
if (nav?.type === "back_forward" && nav.notRestoredReasons) {
  const reasons = nav.notRestoredReasons.reasons?.map((r) => r.reason) ?? [];
  navigator.sendBeacon("/rum/bfcache", JSON.stringify({ url: location.href, reasons }));
}

Test eligibility in Chrome DevTools under Application > Back/forward cache > Test back/forward cache, which lists actionable, pending-support and not-actionable reasons. Browser DevTools walks through the panel.

Speculation Rules: prefetch and prerender the next page

The Speculation Rules API lets a page tell the browser which URLs to prefetch (download the document) or prerender (load and render the whole page, including running its scripts, in a hidden tab-like context) before the user navigates. A prerendered page activates instantly on click; a prefetched one removes the network round trip for the HTML.

Rules are JSON in a <script type="speculationrules"> element or in a separate file referenced by the Speculation-Rules HTTP response header (Chrome 121; served as application/speculationrules+json):

layout.html
<script type="speculationrules">
{
  "prefetch": [
    {
      "where": {
        "and": [
          { "href_matches": "/*" },
          { "not": { "href_matches": "/logout" } },
          { "not": { "href_matches": "/cart/*" } },
          { "not": { "href_matches": "/api/*" } },
          { "not": { "selector_matches": "[rel~=nofollow], [data-no-prefetch]" } }
        ]
      },
      "eagerness": "moderate"
    }
  ],
  "prerender": [
    {
      "where": { "selector_matches": "a[data-prerender]" },
      "eagerness": "moderate"
    }
  ]
}
</script>

Document rules ("where", Chrome 121) select links on the page by URL pattern (href_matches, which uses URL Pattern syntax) and CSS selector (selector_matches), combined with and, or and not. List rules ("urls": [...], Chrome 109) name URLs explicitly.

Eagerness decides when a matching link is speculated. Chrome's documented behavior:

eagerness Desktop trigger Mobile trigger Chrome concurrent limit (prefetch / prerender)
immediate As soon as the rule is observed Same 50 / 10
eager 10 ms pointer hover (since Chrome 143) Link in the viewport for 50 ms (since January 2026) 2 / 2, first in first out
moderate 200 ms hover, or pointerdown if earlier Viewport heuristics after scrolling stops (since August 2025) 2 / 2, first in first out
conservative pointerdown or touch start Same 2 / 2, first in first out

List rules default to immediate and document rules to conservative. Chrome also skips speculation entirely when the user has Save-Data enabled, when Energy Saver is on with a low battery, under memory pressure, when the "Preload pages" setting is off, and for pages in background tabs.

The server sees speculative requests with a Sec-Purpose: prefetch header, or Sec-Purpose: prefetch;prerender for prerenders. Return a non-2xx status to refuse a speculation, and do not perform side effects (counting a view, consuming a one-time link) on speculative requests. MDN notes that Chrome keeps prefetched responses for five minutes, so a prefetched page can be up to five minutes stale when used.

Speculation Rules and your service worker

This is where PWAs used to lose out, and where 2025 changed things:

  • Prefetch through the worker (Chrome 138). Before Chrome 138, Chrome cancelled a speculation-rules prefetch as soon as it detected that the target URL was controlled by a service worker, so installed PWAs with a worker gained nothing from prefetch rules. Since Chrome 138, the prefetch request goes through your fetch handler and the response the worker produces is stored in the prefetch cache, so the later navigation is served from it. Enterprise administrators can switch this off with the PrefetchWithServiceWorkerEnabled policy. A network-first navigation handler therefore runs at prefetch time, not at click time; the response is up to five minutes old when the user clicks, which is usually fine for content pages.
  • Prerender runs a full navigation. A prerendered page is a real document with a real navigation request, so your worker's navigation handler answers it like any other. Inside the prerendered page, navigator.serviceWorker.register(), ServiceWorkerRegistration.update() and unregister(), and ServiceWorker.postMessage() are deferred until activation, as are permission prompts and many device APIs.
  • Analytics and side effects. Scripts in a prerendered page run before the user sees it. Gate page-view analytics on activation:
analytics.js
function whenActivated(callback) {
  if (document.prerendering) {
    document.addEventListener("prerenderingchange", callback, { once: true });
  } else {
    callback();
  }
}

whenActivated(() => {
  const [nav] = performance.getEntriesByType("navigation");
  // activationStart > 0 means this page was prerendered; report it as a dimension.
  const prerendered = (nav?.activationStart ?? 0) > 0;
  navigator.sendBeacon("/rum/pageview", JSON.stringify({ url: location.href, prerendered }));
});

document.prerendering, the prerenderingchange event and PerformanceNavigationTiming.activationStart are Chromium-only; the fallback branch simply runs immediately elsewhere.

Cross-browser status of speculative loading

  • Chromium (Chrome, Edge, Opera, Samsung Internet): prefetch and prerender, document rules, the HTTP header, tag (Chrome 136) and target_hint (Chrome 138, prerender only, for prerendering into a new tab).
  • Safari 26.2: the Speculation Rules API is implemented behind a feature flag, prefetch only (no prerender); for document rules only conservative eagerness is supported, with moderate falling back to it.
  • Firefox: no Speculation Rules. <link rel="prefetch"> works and sends Sec-Purpose: prefetch since Firefox 115.

A prerender_until_script action, which prefetches and parses the page and loads its subresources but pauses before the first script runs, was offered as a Chrome origin trial from Chrome 144 (January 2026) through Chrome 150. It is not enabled by default in stable Chrome; check its Chrome Platform Status entry before relying on it.

Experimental

prerender_until_script has only been available through an origin trial (Chrome 144 to 150) and may change or be removed. Speculation Rules as a whole is still marked experimental on MDN because only Chromium ships it by default.

Streaming composition for MPAs

Server-rendered MPA pages repeat the same header, navigation and footer on every page. A service worker can exploit that: it responds to a navigation with a ReadableStream that immediately emits a cached header, then streams the page-specific content from the network (a partial that omits the chrome), then emits a cached footer. The browser paints the header and navigation from cache within milliseconds, exactly like an app shell, while the content streams in, and the document is still a real per-URL document that works without the worker.

sw-compose.js
// Compose header + network body partial + footer into one streamed document.
// Assumes the server returns only the <main> contents when it sees the
// X-Partial: 1 request header (and sends Vary: X-Partial), and that
// /partials/header.html and /partials/footer.html were precached.
async function composedNavigation(event, precacheName) {
  const url = new URL(event.request.url);
  const cache = await caches.open(precacheName);
  const [header, footer] = await Promise.all([
    cache.match("/partials/header.html"),
    cache.match("/partials/footer.html"),
  ]);
  if (!header || !footer) return fetch(event.request); // Precache damaged: no composition.

  // Start the partial request now so it runs while the header streams out.
  // The .catch() turns a network failure into a value, so the promise is never
  // an unhandled rejection while the header is still being written.
  const partial = fetch(url.pathname + url.search, {
    headers: { "X-Partial": "1" },
    credentials: "same-origin",
  }).catch((error) => error);

  const encoder = new TextEncoder();
  const { readable, writable } = new TransformStream();

  const compose = (async () => {
    const writer = writable.getWriter();
    // Copies one response body into the composed stream, honoring backpressure.
    const copy = async (response) => {
      const reader = response.body.getReader();
      for (;;) {
        const { done, value } = await reader.read();
        if (done) return;
        await writer.ready;
        await writer.write(value);
      }
    };
    try {
      await copy(header);
      const body = await partial;
      if (body instanceof Response && body.ok) {
        await copy(body);
      } else if (body instanceof Response) {
        // The server answered, but not with content (404, 500...). The status line
        // is already sent as 200, so render the error inline instead.
        await writer.write(encoder.encode(
          `<main><h1>Page unavailable</h1><p>The server returned ${body.status}.</p></main>`,
        ));
      } else {
        // Network failure after the header was sent: an inline offline message.
        await writer.write(encoder.encode(
          "<main><h1>Offline</h1><p>This page is not available offline.</p></main>",
        ));
      }
      await copy(footer);
      await writer.close();
    } catch (error) {
      await writer.abort(error).catch(() => {});
    }
  })();

  // Keep the worker alive until the whole document has been written.
  event.waitUntil(compose);

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

Composition trades flexibility for speed: the <title>, meta tags and <head> resources are baked into the cached header, so page-specific metadata has to be set from the body (a small inline script updating document.title) or the header must end before <title>, and the server must support partial responses keyed by a request header with a matching Vary header. Because the status code comes from the worker and the header goes out before the partial's status is known, a missing page returns 200; if accurate status codes matter for a section (they rarely do for installed users, since crawlers never run the worker), await the partial's headers before writing anything and return a plain network response on a non-2xx status. The TransformStream writer's ready promise applies backpressure, so a slow page does not make the worker buffer the entire partial in memory. Streaming Responses covers these trade-offs, error handling mid-stream and server implementations in depth; Navigation Preload shows how to use the Service-Worker-Navigation-Preload header to request the partial in parallel with worker start-up.

Memory and long-running sessions

Installed PWAs change the memory picture because their windows stay open far longer than browser tabs. A desktop PWA pinned to the taskbar or a mobile PWA the OS keeps in its app switcher can run the same document for days.

In an SPA, the JavaScript heap lives as long as the document. Every route change adds and removes DOM, subscribes and unsubscribes listeners, and fills in-memory caches (query caches, normalized stores, image object URLs). Small leaks that are invisible in a five-minute test session accumulate:

  • Detached DOM trees kept alive by closures in event handlers, observers (IntersectionObserver, ResizeObserver, MutationObserver) that are never disconnected, and timers that are never cleared.
  • Unbounded client caches, for example a data-fetching library whose cache time is infinite, or a store that keeps every page of an infinite list.
  • URL.createObjectURL() results that are never revoked, which pin the underlying Blob in memory.
  • Framework devtools hooks and verbose logging left enabled in production.

In an MPA, each navigation starts with an empty heap. The previous document is either destroyed or frozen in the bfcache, where browsers cap how many pages they keep and evict under memory pressure. A leak in one page's script cannot outlive that page, which makes MPAs forgiving of mediocre client code. The cost moves elsewhere: each navigation re-parses and re-executes the page's scripts, so large shared bundles are paid on every page (the HTTP cache and V8's code cache soften the parse and compile cost on repeat loads).

Practical guidance for SPA PWAs:

  • Treat memory as a release metric. performance.measureUserAgentSpecificMemory() (Chromium 89 and later, experimental, only in cross-origin isolated contexts) returns a byte estimate you can sample in the field; in the lab, take heap snapshots in DevTools after cycling through routes 20 times and compare.
  • Use AbortController signals for every listener and request tied to a view, and abort on route exit. The router above passes event.signal for exactly this reason.
  • Bound every in-memory cache by entry count or age.
  • Consider a soft reload strategy: when the app has been in the background for a long time and a new service worker is waiting, reload the document on the next visibilitychange to visible before the user interacts. That resets the heap and picks up the new shell at a moment the user does not notice.

The service worker is a separate process-level concern in both models: its memory is reclaimed whenever the browser terminates it, so a worker that keeps large objects in global variables loses them unpredictably. Keep worker state in Cache Storage or IndexedDB (IndexedDB).

Performance and Core Web Vitals

The two models distribute cost differently across a session:

Moment SPA with app shell MPA with per-page caching
First visit (no worker yet) Shell HTML, then JS, then data: LCP waits for all three unless the server also renders the route Server HTML is the content: fastest LCP on a good server
Repeat launch Shell from cache: very fast FCP; LCP still waits for data Page from worker (network-first with preload or cache): fast LCP, first paint needs HTML
In-app navigation Soft navigation: only data and chunks; can be near-instant with local data Hard navigation: HTML, CSS and JS re-evaluation; near-instant with prerender, bfcache or a cached page
Back navigation Router re-renders bfcache restore: instant
Interaction responsiveness (INP) Large bundles and hydration at launch; route renders are interactions Less JavaScript per page; each page re-hydrates its islands

Two measurement caveats follow from the table:

  • Field data sees mostly hard navigations. Chrome's CrUX data is built from hard navigations. An SPA's route changes are soft navigations: Chrome 151 enables soft navigation measurement by default and exposes route changes as SoftNavigationEntry objects (entry type "soft-navigation"), and the web-vitals library can report per-route metrics, but how CrUX will report soft navigations is still to be determined. An SPA can look better in CrUX than it feels (only the landing route's LCP counts) or worse (INP and CLS of the whole session attributed to the landing URL). Core Web Vitals covers soft-navigation measurement in detail.
  • bfcache restores and prerender activations are real page views. They produce near-zero LCP for MPAs, and the web-vitals library reports them. Prerendered pages report metrics relative to activationStart.

For loading-phase techniques in both models (preloading LCP images, code splitting, compression), see Loading Performance; for main-thread work after load, see Runtime Performance.

SEO for SPA and MPA PWAs

Search engines index documents at URLs. An MPA gives them exactly that: every URL returns its own complete HTML with a real status code, title, canonical link and structured data, and nothing depends on the service worker, which crawlers do not run.

A client-rendered SPA hands crawlers an empty shell. Google renders JavaScript in a second phase with an evergreen Chromium, but rendering is deferred, resource-limited and not guaranteed for every crawler; other search engines, social previews and many AI crawlers read only the raw HTML. The shell's 200 OK for every URL creates soft-404 problems, and per-route titles and meta tags must be injected client-side.

The consequences for architecture:

  • If search traffic matters, render routes on the server (SSR, SSG or ISR), even if the app then takes over routing as an SPA. Frameworks that do this (Next.js, Nuxt, SvelteKit, Remix-style routers, Astro) give you an SPA's in-app navigation with an MPA's crawlable documents.
  • Keep the service worker's navigation strategy from overriding server HTML for crawlable sections. A worker that serves an SPA shell for /blog/ URLs is harmless to crawlers (they have no worker), but it means your real users see different HTML from what you tested for SEO.
  • Signed-in application areas (/app/) usually do not need indexing at all, which is exactly where an SPA shell costs nothing in SEO terms.

SEO for PWAs goes into rendering, status codes, canonical URLs and testing.

Hybrid architectures and islands

Few production PWAs are purely one model. The common hybrids:

Content MPA plus application SPA. Marketing pages, documentation and the blog are server-rendered or static documents; the signed-in product lives under /app/ as an SPA. One service worker at scope / routes navigations by path: network-first or stale-while-revalidate per page for content, the precached shell for /app/. The Architecture overview contains a complete router of this shape.

Server-rendered pages that upgrade to client routing. Frameworks such as Next.js (App Router), Nuxt and SvelteKit render the first request on the server and then intercept links, fetching server-rendered payloads (React Server Components payloads, JSON, or HTML fragments) for later navigations. To the service worker these apps look like MPAs on the first navigation of each session and like SPAs afterwards. The worker's navigation strategy should therefore be network-first per page (never an SPA shell fallback, because every URL has its own server document), and the payload requests they make for client navigations are data requests to cache or not by their own rules. Check what your framework sends: client-navigation payload requests often carry framework-specific headers or query parameters (such as Next.js's RSC request header), and a worker that caches them under the page URL without accounting for those headers can serve a payload where HTML is expected.

Islands MPAs. Astro-style pages ship static HTML and hydrate only interactive components. They are MPAs for routing, bfcache, speculation rules and View Transitions, and they have the smallest per-page JavaScript cost. The worker caches each page and treats island endpoints (Astro's server islands are fetched from /_server-islands/... URLs) as data. Astro can also opt into a client router (<ClientRouter />) that turns navigations into same-document swaps with View Transitions; with cross-document View Transitions available in Chromium and Safari, many sites now drop it and stay a pure MPA.

Multiple SPAs under one origin. Large products sometimes ship several independent SPAs (/app/, /admin/, /editor/), each with its own shell. One worker can serve several shells: the navigation fallback picks the shell by path prefix. Precache all shells if they are small, or precache the main one and cache the others on first use.

In every hybrid, the rule from the architecture overview holds: the worker's navigation route for a path prefix must match how that prefix is rendered. Write the mapping down (a table in your repository is enough) and generate the worker's routes and the server's rewrite rules from it.

Choosing between SPA and MPA

Decide per section of the URL space, not per product.

flowchart TD
    A["Section of the app"] --> B{"Mostly reading content that should be indexed?"}
    B -- yes --> M["MPA: server or static HTML per page"]
    B -- no --> C{"Screens share long-lived state? (open editor, media playback, live connection)"}
    C -- yes --> S["SPA: app shell plus client router"]
    C -- no --> D{"Must every deep link work offline?"}
    D -- yes --> E{"Is the data local (IndexedDB) or cacheable?"}
    E -- yes --> S
    E -- no --> M2["MPA with save-for-offline and an offline page"]
    D -- no --> F{"Is most JavaScript per page small (islands)?"}
    F -- yes --> M
    F -- no --> G{"Team already runs an SSR framework with client routing?"}
    G -- yes --> H["Hybrid: SSR first load, client routing after, network-first worker"]
    G -- no --> M

The criteria behind the flowchart:

Criterion Favors SPA Favors MPA
State that must survive navigation (audio or video playing, WebSocket or WebRTC session, unsaved editor state, drag in progress) ✅ Survives route changes ❌ Lost on every navigation unless persisted
Offline access to any route ✅ If data is local ⚠️ Only cached pages
First-visit LCP from search or social ⚠️ Needs SSR in addition ✅ Native
SEO without extra work ❌ ✅
Instant in-app navigation ✅ Soft navigations ✅ With prerender, bfcache and cached pages (Chromium; Safari partly)
Animated transitions ✅ Same-document View Transitions in all three engines ⚠️ Cross-document in Chromium and Safari, not Firefox
Long-running memory stability ⚠️ Needs discipline ✅ Resets per page
JavaScript required to function ❌ Yes ✅ No
Service worker complexity Simple navigation handler, complex update story Per-page caching rules, simple updates
Service worker update story Old shell keeps running in open windows; needs prompts New pages pick up the new worker at the next navigation

A few decision heuristics that hold up in practice:

  • Tools and editors (design tools, IDEs, spreadsheets, chat, email, maps) are SPAs. Their value is in state that lives across screens.
  • Content with light interactivity (news, documentation, e-commerce catalogs, blogs, marketing) are MPAs, increasingly with islands, prerendering and cross-document transitions.
  • Transactional apps (banking, booking, admin dashboards) can go either way; an MPA with an islands approach is simpler to secure and update, while an SPA gives richer offline behavior if you invest in an offline-first data layer.
  • If you are not sure, start with an MPA. Adding a service worker, View Transitions and speculation rules to a server-rendered site is incremental; turning an SPA into crawlable, fast-first-visit documents is a rewrite. Migrating an Existing Site shows the incremental path, and When to Build a PWA covers the product decision.

Example service worker configurations

The two workers below are complete. Each comes in a dependency-free version and a Workbox version built with injectManifest (for example by the Vite PWA Plugin).

SPA: precached shell with navigation fallback

What the SPA worker does and why:

  • Precaches the shell and its hashed assets at install, bypassing the HTTP cache.
  • Answers every navigation under /app/ with the shell, except for a denylist of server-only paths and file-like URLs.
  • Disables navigation preload, because network HTML is never used.
  • Does not call skipWaiting() automatically; open windows keep running the old shell's JavaScript, so the page shows an update prompt and posts SKIP_WAITING when the user accepts. See Updating Service Workers.
  • Uses network-first for API data with a cached fallback, so previously loaded data renders offline. For a real offline-first app, the data lives in IndexedDB instead; see Offline-First Data & Sync.
app/sw.js
// Service worker for an SPA PWA scoped to /app/.
// A build step normally injects VERSION and PRECACHE_URLS; any change to them
// changes this file's bytes, which is what triggers the update check to install.
const VERSION = "2026-09-25.1";
const SHELL_CACHE = `app-shell-${VERSION}`;
const RUNTIME_CACHE = "app-runtime-v1";
const SHELL_URL = "/app/index.html";

const PRECACHE_URLS = [
  SHELL_URL,
  "/app/assets/index.4f1c2a9e.js",
  "/app/assets/index.91be03d4.css",
  "/app/assets/vendor.0c7d11f2.js",
  "/app/icons/icon-192.png",
];

// Navigations the shell must never answer. Keep this list in sync with the
// client router's server-only list (generate both from one source at build time).
const NAVIGATION_DENYLIST = [
  /^\/app\/auth\//, // OAuth / OIDC callbacks handled by the server
  /^\/app\/export\//, // file downloads rendered by the server
  /^\/app\/logout$/, // must reach the server to clear HttpOnly cookies
  /\/[^/?]+\.[^/]+$/, // anything that looks like a file: /app/report.pdf
];

self.addEventListener("install", (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(SHELL_CACHE);
      // cache: "reload" bypasses the HTTP cache so a stale shell is never precached.
      await cache.addAll(PRECACHE_URLS.map((url) => new Request(url, { cache: "reload" })));
    })(),
  );
  // No skipWaiting() here: an SPA tab keeps running the old shell's JavaScript,
  // so the page decides when to switch (see the message handler below).
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      // The shell never uses network HTML, so navigation preload would only
      // waste bandwidth. The setting persists on the registration, so turn it
      // off explicitly in case an earlier version enabled it.
      if (self.registration.navigationPreload) {
        await self.registration.navigationPreload.disable();
      }
      const names = await caches.keys();
      await Promise.all(
        names
          .filter((name) => name.startsWith("app-shell-") && name !== SHELL_CACHE)
          .map((name) => caches.delete(name)),
      );
    })(),
  );
});

self.addEventListener("message", (event) => {
  // The page shows "Update available" and posts this when the user accepts.
  if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
});

self.addEventListener("fetch", (event) => {
  const { request } = event;
  if (request.method !== "GET") return;
  const url = new URL(request.url);
  if (url.origin !== self.location.origin) return;

  if (request.mode === "navigate") {
    if (!url.pathname.startsWith("/app/")) return; // outside the SPA: browser default
    if (NAVIGATION_DENYLIST.some((re) => re.test(url.pathname))) return;
    event.respondWith(serveShell(request));
    return;
  }

  if (url.pathname.startsWith("/app/assets/") || url.pathname.startsWith("/app/icons/")) {
    event.respondWith(cacheFirst(request));
    return;
  }

  if (url.pathname.startsWith("/app/api/") && url.searchParams.get("cache") !== "no") {
    event.respondWith(networkFirstData(event));
  }
});

async function serveShell(request) {
  const cached = await caches.match(SHELL_URL, { cacheName: SHELL_CACHE });
  if (cached) return cached;
  // The shell was evicted or the install is incomplete: go to the network.
  // The server returns the shell for every client route, so this still works.
  try {
    return await fetch(request);
  } catch {
    return new Response("<!doctype html><title>Offline</title><p>This app needs one online visit to install.</p>", {
      status: 503,
      headers: { "Content-Type": "text/html; charset=utf-8" },
    });
  }
}

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;
  const response = await fetch(request);
  if (response.ok) {
    const cache = await caches.open(RUNTIME_CACHE);
    await cache.put(request, response.clone());
  }
  return response;
}

async function networkFirstData(event) {
  const { request } = event;
  const cache = await caches.open(RUNTIME_CACHE);
  try {
    const response = await fetch(request);
    if (response.ok) event.waitUntil(cache.put(request, response.clone()));
    return response;
  } catch (error) {
    const cached = await cache.match(request);
    if (cached) return cached;
    throw error; // Let the app show its own offline state for this data.
  }
}
app/sw.js
// Workbox (injectManifest) version of the SPA worker. Build with
// workbox-build or the Vite PWA plugin, which replace self.__WB_MANIFEST.
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from "workbox-precaching";
import { NavigationRoute, registerRoute } from "workbox-routing";
import { CacheFirst, NetworkFirst } from "workbox-strategies";
import { ExpirationPlugin } from "workbox-expiration";
import * as navigationPreload from "workbox-navigation-preload";

// The shell never uses network HTML. disable() turns preload off in activate,
// undoing any earlier version that enabled it (the setting persists).
navigationPreload.disable();
cleanupOutdatedCaches();
precacheAndRoute(self.__WB_MANIFEST); // shell, hashed JS/CSS, icons

// Every in-scope navigation gets the precached shell, except server-only paths.
// Workbox matches these regexes against pathname + search; denylist wins.
registerRoute(
  new NavigationRoute(createHandlerBoundToURL("/app/index.html"), {
    allowlist: [/^\/app\//],
    denylist: [/^\/app\/auth\//, /^\/app\/export\//, /^\/app\/logout$/, /\/[^/?]+\.[^/]+$/],
  }),
);

registerRoute(
  ({ url }) => url.pathname.startsWith("/app/api/"),
  new NetworkFirst({
    cacheName: "app-api",
    networkTimeoutSeconds: 4,
    plugins: [new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 7 * 24 * 60 * 60 })],
  }),
);

registerRoute(
  ({ request }) => request.destination === "image",
  new CacheFirst({
    cacheName: "app-images",
    plugins: [new ExpirationPlugin({ maxEntries: 100, purgeOnQuotaError: true })],
  }),
);

// The page posts this after the user accepts an "Update available" prompt.
self.addEventListener("message", (event) => {
  if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
});

The page side pairs this with registration and an update prompt:

app/register-sw.js
if ("serviceWorker" in navigator) {
  window.addEventListener("load", async () => {
    const registration = await navigator.serviceWorker.register("/app/sw.js", { scope: "/app/" });

    const promptIfWaiting = (worker) => {
      if (!worker || !navigator.serviceWorker.controller) return; // first install: nothing to replace
      showUpdateBanner(() => worker.postMessage({ type: "SKIP_WAITING" }));
    };

    promptIfWaiting(registration.waiting);
    registration.addEventListener("updatefound", () => {
      const worker = registration.installing;
      worker?.addEventListener("statechange", () => {
        if (worker.state === "installed") promptIfWaiting(worker);
      });
    });

    // Reload once when the new worker takes control, so the new shell runs.
    let reloaded = false;
    navigator.serviceWorker.addEventListener("controllerchange", () => {
      if (reloaded) return;
      reloaded = true;
      location.reload();
    });
  });
}

function showUpdateBanner(onAccept) {
  const banner = document.getElementById("update-banner");
  banner.hidden = false;
  banner.querySelector("button").addEventListener("click", onAccept, { once: true });
}

MPA: per-page network-first with offline fallback

What the MPA worker does and why:

  • Precaches the start URL, the offline page and the shared hashed assets. Precached pages are fetched one by one and passed through cleanRedirect(), so a redirect on the server (for example /offline to /offline.html) cannot poison navigations.
  • Declares static routes for checkout and API URLs in engines that support them, so those requests never start the worker; the fetch handler implements the same behavior for Firefox.
  • Enables navigation preload and uses event.preloadResponse for every navigation.
  • Serves pages network-first with a 3.5-second timeout, falls back to the cached copy under a normalized key, keeps waiting for the network if nothing is cached, and only then shows the offline page. A response that loses the race is still stored.
  • Stores only same-origin 200 HTML without no-store or private, adds X-Page-Title and X-Cached-At headers for the offline page's list and a "saved at" indicator, and caps the pages cache.
  • Calls skipWaiting() but not clients.claim(). Open pages that the old worker controls switch to the new worker as soon as it activates, because they are controlled by the registration and its active worker changes; a claim is not needed for that. Pages that loaded without a controller (the first visit) stay uncontrolled until they navigate, which for an MPA is seconds or minutes, and not claiming keeps bfcached pages from being evicted. Skipping waiting is safe here only because an MPA page requests its subresources at load and never lazy-loads chunks that a new version could remove; see the lifecycle and pitfalls before copying it into an app that does.
  • Accepts CLEAR_PAGES (sent at sign-out) and SAVE_PAGE messages.
sw.js
// Service worker for a server-rendered MPA PWA scoped to /.
const VERSION = "2026-09-25.1";
const PRECACHE = `precache-${VERSION}`;
const PAGES_CACHE = "pages-v1"; // offline.html lists this cache; keep the names in sync
const ASSETS_CACHE = "assets-v1";
const MAX_PAGES = 60;
const NETWORK_TIMEOUT_MS = 3500;

const PRECACHE_URLS = [
  "/", // start_url in the manifest: must open offline
  "/offline.html",
  "/assets/site.7c2e19ab.css",
  "/assets/site.1d0f5e77.js",
];

// Sections whose HTML must never be stored or replayed.
const NETWORK_ONLY = [/^\/checkout\//, /^\/account\/security/, /^\/signin/, /^\/logout$/];

// Query parameters that never change the document.
const TRACKING_PARAMS = [/^utm_/, /^gclid$/, /^fbclid$/, /^msclkid$/, /^mc_(cid|eid)$/];

self.addEventListener("install", (event) => {
  event.addRoutes?.([
    // Static routing (Chromium 123+, Safari 27+): these never start the worker.
    { condition: { urlPattern: "/checkout/*", requestMode: "navigate" }, source: "network" },
    { condition: { urlPattern: "/api/*" }, source: "network" },
  ]).catch(() => {}); // Invalid rules must not fail the install.

  event.waitUntil(
    (async () => {
      const cache = await caches.open(PRECACHE);
      for (const url of PRECACHE_URLS) {
        // Fetch individually so redirected responses can be cleaned before storage.
        const response = await fetch(new Request(url, { cache: "reload" }));
        if (!response.ok) throw new Error(`Precache failed for ${url}: ${response.status}`);
        await cache.put(url, await cleanRedirect(response));
      }
    })(),
  );
  // MPA pages load a fresh document on every navigation and request no
  // lazy chunks, so taking over quickly is safe here. Open controlled pages
  // switch to this worker when it activates. In an app whose old pages can
  // request files this version removed, wait for the user instead (see
  // service-workers/pitfalls.md, "skipWaiting() breaking lazy-loaded chunks").
  self.skipWaiting();
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      if (self.registration.navigationPreload) {
        await self.registration.navigationPreload.enable();
      }
      const keep = new Set([PRECACHE, PAGES_CACHE, ASSETS_CACHE]);
      const names = await caches.keys();
      await Promise.all(names.filter((n) => !keep.has(n)).map((n) => caches.delete(n)));
      // No clients.claim(): uncontrolled pages get the worker on their next
      // navigation anyway, and claiming evicts bfcached pages.
    })(),
  );
});

self.addEventListener("message", (event) => {
  // Sent by the page on sign-out: remove every stored page of the old user.
  if (event.data?.type === "CLEAR_PAGES") {
    event.waitUntil(caches.delete(PAGES_CACHE));
  }
  // Sent by a "Save for offline" button with the page URL.
  if (event.data?.type === "SAVE_PAGE" && typeof event.data.url === "string") {
    event.waitUntil(savePage(event.data.url));
  }
});

self.addEventListener("fetch", (event) => {
  const { request } = event;
  if (request.method !== "GET") return;
  const url = new URL(request.url);
  if (url.origin !== self.location.origin) return;

  if (request.mode === "navigate") {
    if (NETWORK_ONLY.some((re) => re.test(url.pathname))) {
      event.respondWith(networkOnlyPage(event));
    } else {
      event.respondWith(networkFirstPage(event));
    }
    return;
  }

  if (url.pathname.startsWith("/assets/")) {
    event.respondWith(cacheFirstAsset(request));
  }
});

function pageKey(input) {
  const url = new URL(input);
  for (const name of [...url.searchParams.keys()]) {
    if (TRACKING_PARAMS.some((re) => re.test(name))) url.searchParams.delete(name);
  }
  url.searchParams.sort(); // ?a=1&b=2 and ?b=2&a=1 are the same page
  url.hash = "";
  return url.href;
}

function isStorablePage(response) {
  if (!response || !response.ok || response.type !== "basic") return false;
  const type = response.headers.get("Content-Type") || "";
  const cacheControl = response.headers.get("Cache-Control") || "";
  return type.includes("text/html") && !/\b(no-store|private)\b/i.test(cacheControl);
}

async function networkFirstPage(event) {
  const key = pageKey(event.request.url);
  const cache = await caches.open(PAGES_CACHE);

  const network = (async () => {
    const response = (await event.preloadResponse) || (await fetch(event.request));
    if (isStorablePage(response)) {
      // Clone before the page consumes the body; store even if the timeout
      // already answered from the cache, so the next visit is fresh.
      const copy = response.clone();
      event.waitUntil(storePage(cache, key, copy));
    }
    return response;
  })();
  // Keep the event alive until the network settles, whichever response wins.
  event.waitUntil(network.then(() => undefined, () => undefined));

  try {
    return await withTimeout(network, NETWORK_TIMEOUT_MS);
  } catch {
    // Pages cache first, then the precache, which holds the start URL ("/").
    const cached = (await cache.match(key)) || (await caches.match(key, { cacheName: PRECACHE }));
    if (cached) return cached;
    // Timed out with nothing cached: keep waiting for the network rather than
    // showing the offline page to someone who is merely on a slow connection.
    try {
      return await network;
    } catch {
      return offlinePage();
    }
  }
}

async function networkOnlyPage(event) {
  // Reached in engines without Static Routing (or for sign-in and security
  // pages, which have no static route): never read from or write to a cache.
  try {
    return (await event.preloadResponse) || (await fetch(event.request));
  } catch {
    return offlinePage();
  }
}

async function offlinePage() {
  const cached = await caches.match("/offline.html", { cacheName: PRECACHE });
  return cached || Response.error();
}

async function storePage(cache, key, response) {
  // Record the title so offline.html can list saved pages by name. Reading the
  // body here is fine: this is the clone, not the stream the page is rendering.
  const html = await response.text();
  const title = html.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() ?? "";
  const headers = new Headers(response.headers);
  headers.set("X-Page-Title", encodeURIComponent(title));
  headers.set("X-Cached-At", new Date().toISOString());
  await cache.put(key, new Response(html, { status: response.status, statusText: response.statusText, headers }));
  await trimCache(cache, MAX_PAGES);
}

async function savePage(url) {
  const response = await fetch(url, { credentials: "same-origin" });
  if (isStorablePage(response)) {
    const cache = await caches.open(PAGES_CACHE);
    await storePage(cache, pageKey(url), await cleanRedirect(response));
  }
}

async function cacheFirstAsset(request) {
  const cache = await caches.open(ASSETS_CACHE);
  const cached = (await caches.match(request, { cacheName: PRECACHE })) || (await cache.match(request));
  if (cached) return cached;
  const response = await fetch(request);
  if (response.ok) await cache.put(request, response.clone());
  return response;
}

async function cleanRedirect(response) {
  if (!response.redirected) return response;
  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers: response.headers,
  });
}

function withTimeout(promise, ms) {
  let timer;
  return Promise.race([
    promise,
    new Promise((_, reject) => {
      timer = setTimeout(() => reject(new Error("timeout")), ms);
    }),
  ]).finally(() => clearTimeout(timer));
}

async function trimCache(cache, max) {
  // keys() returns entries in insertion order; re-putting a page moves it last.
  const keys = await cache.keys();
  const excess = keys.length - max;
  for (let i = 0; i < excess; i += 1) await cache.delete(keys[i]);
}
sw.js
// Workbox (injectManifest) version of the MPA worker.
import { cleanupOutdatedCaches, matchPrecache, precacheAndRoute } from "workbox-precaching";
import { registerRoute, setCatchHandler } from "workbox-routing";
import { CacheFirst, NetworkFirst, NetworkOnly } from "workbox-strategies";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
import { ExpirationPlugin } from "workbox-expiration";
import * as navigationPreload from "workbox-navigation-preload";

// Workbox strategies use event.preloadResponse automatically once enabled.
navigationPreload.enable();
cleanupOutdatedCaches();
// precacheAndRoute() registers its route first, so any precached URL wins over the
// routes below: "/" is served cache-first until the next deploy. That suits a static
// start page; if "/" is personalized, leave it out of the manifest.
precacheAndRoute(self.__WB_MANIFEST); // "/", /offline.html, hashed CSS and JS

const TRACKING = /^(utm_.+|gclid|fbclid|msclkid)$/;

// Normalize page cache keys for both reads and writes.
const stripTrackingParams = {
  cacheKeyWillBeUsed: async ({ request }) => {
    const url = new URL(request.url);
    for (const name of [...url.searchParams.keys()]) {
      if (TRACKING.test(name)) url.searchParams.delete(name);
    }
    url.searchParams.sort();
    return url.href;
  },
};

// Pages that must never be stored: straight to the network.
registerRoute(
  ({ request, url }) =>
    request.mode === "navigate" && /^\/(checkout|signin|logout|account\/security)/.test(url.pathname),
  new NetworkOnly(),
);

// Every other page: network-first, 3.5 s timeout, cached copy as fallback.
registerRoute(
  ({ request }) => request.mode === "navigate",
  new NetworkFirst({
    cacheName: "pages-v1",
    networkTimeoutSeconds: 3.5,
    plugins: [
      stripTrackingParams,
      new CacheableResponsePlugin({ statuses: [200] }),
      new ExpirationPlugin({ maxEntries: 60, purgeOnQuotaError: true }),
    ],
  }),
);

registerRoute(
  ({ url }) => url.pathname.startsWith("/assets/"),
  new CacheFirst({
    cacheName: "assets-v1",
    plugins: [new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 365 * 24 * 60 * 60 })],
  }),
);

// Anything that failed without a cached copy: offline page for documents.
setCatchHandler(async ({ request }) => {
  if (request.destination === "document") {
    return (await matchPrecache("/offline.html")) || Response.error();
  }
  return Response.error();
});

// Same reasoning as the vanilla worker: safe only because MPA pages never
// request files that a newer deploy removed.
self.skipWaiting();

The Workbox version stores pages without the X-Page-Title header, so the offline page shown earlier falls back to listing paths; add a cacheWillUpdate plugin that rewrites the response if you want titles. The page side of the MPA worker is only registration plus the sign-out hook:

register-sw.js
if ("serviceWorker" in navigator) {
  // Register after load so the first visit's page resources win the bandwidth.
  window.addEventListener("load", () => {
    navigator.serviceWorker.register("/sw.js").catch((error) => {
      console.error("Service worker registration failed", error);
    });
  });

  // Sign-out forms post to /logout; clear stored pages first so the next
  // user on this device can never open the previous user's pages offline.
  document.querySelector("form[action='/logout']")?.addEventListener("submit", () => {
    navigator.serviceWorker.controller?.postMessage({ type: "CLEAR_PAGES" });
  });
}

For a more robust sign-out, also send a Clear-Site-Data header on the logout response (remember that its "storage" type also unregisters your service workers); see Service Worker Security and Privacy & Storage Partitioning.

Browser support

Support data as of September 2026. For live data, see MDN: Navigation API, MDN: Speculation Rules API, MDN: View Transition API and caniuse.

Feature Chrome / Edge Safari (macOS, iOS, iPadOS) Firefox
Navigation API (navigation, navigate event, intercept()) ✅ 102 (intercept() 105) ✅ 26.2 ✅ 147
NavigateEvent.sourceElement ✅ 135 ✅ 26.2 ✅ 147
URLPattern ✅ 95 ✅ 26 ✅ 142
Same-document View Transitions ✅ 111 ✅ 18 ✅ 144
Cross-document View Transitions (@view-transition) ✅ 126 ✅ 18.2 ❌
pageswap / pagereveal ✅ 124 / 123 ⚠️ 18.2 ❌
Speculation Rules prefetch ✅ 110 🧪 26.2 ❌
Speculation Rules prerender ✅ 105 ❌ ❌
Document rules and eagerness ✅ 121 🧪 26.2 ⚠️ ❌
Speculation-Rules HTTP header ✅ 121 🧪 26.2 ❌
Prefetch of service-worker-controlled URLs through the fetch handler ✅ 138 ❌ ❌
document.prerendering, activationStart ✅ 108 ❌ ❌
notRestoredReasons ✅ 125 ❌ ❌
bfcache with pageshow/pagehide persisted ✅ ✅ ✅
Navigation preload (event.preloadResponse) ✅ 59 ✅ 15.4 ✅ 99
Static Routing API (addRoutes()) ✅ 123 ✅ 27 ❌
Soft navigation entries (SoftNavigationEntry) ✅ 151 ❌ ❌
performance.measureUserAgentSpecificMemory() ✅ 89 ❌ ❌

Notes:

  • Chrome and Edge rows apply to desktop and Android; Opera and Samsung Internet follow their Chromium version. iOS and iPadOS home screen web apps use the system WebKit, so they follow the OS's Safari version.
  • ⚠️ Safari does not fire pageswap for cross-origin navigations.
  • 🧪 Safari 26.2's Speculation Rules support is behind a feature flag and covers prefetch only; for document rules it supports only conservative eagerness (moderate falls back to it).
  • Chromium's requires: ["anonymous-client-ip-when-cross-origin"] option for cross-site prefetch is not supported in Edge.

Common pitfalls

  • Using an SPA shell fallback on an MPA. Every URL gets the same document; the page shows the home page content at every address, or a client-side "not found". Match the navigation strategy to how each path is rendered.
  • Forgetting the denylist. OAuth callbacks, logout, downloads and server-rendered sections receive the shell. Keep one list for the router and the worker.
  • Enabling navigation preload for a cache-first shell. Every launch downloads HTML the worker never uses. Disable it explicitly, because the setting persists on the registration.
  • Caching redirected responses and serving them to navigations. Navigations fail with a network error. Rebuild the response with new Response(response.body, ...) or let Workbox's precaching handle it.
  • Caching opaque redirects. A cached opaqueredirect has no destination offline. Store only response.ok responses of type basic.
  • Tracking parameters fragmenting the page cache. ?utm_source=newsletter and the bare URL are different keys. Normalize before reading and writing.
  • Caching personalized pages without a sign-out clean-up. The next user of a shared device opens the previous user's pages offline. Clear the pages cache on sign-out and send Clear-Site-Data.
  • clients.claim() and broadcast postMessage() on every deploy. Both evict bfcached pages. MPAs rarely need either.
  • unload listeners, often added by third-party scripts, block bfcache in Chrome desktop and Firefox. Replace them with pagehide and audit with Permissions-Policy: unload=().
  • Side effects on speculative requests. Prefetches and prerenders hit your server (and, since Chrome 138, your worker) before the user clicks. Exclude logout, add-to-cart and one-time links from speculation rules, and gate analytics on prerenderingchange.
  • Expecting cross-document transitions on app launch or reload. They never run for navigations from browser UI or reloads. Design the launch experience with a fast first paint instead.
  • Awaiting the network in a cross-document transition. A slow server blows the transition's time budget; serve navigations from the worker cache, use navigation preload, or prerender the target.
  • Memory growth in long-lived SPA windows. Test with long sessions, abort per-view work on route exit, and reload at a safe moment when a new worker is waiting.
  • Deep links that 404 on the first visit. An SPA served from object storage needs a rewrite of every client route to the shell on the server; the worker only helps from the second visit on.

Debugging

  • Which strategy answered a navigation? In Chrome DevTools' Network panel, the Size column shows "(ServiceWorker)" for worker responses, and the Timing tab for the document shows the service worker start-up and respondWith phases, plus the Static Routing source when a route matched. In Firefox, requests served by the worker are labeled as such in the Transferred column; in Safari, use Web Inspector's Network tab on the page and a separate inspector for the worker.
  • Inspect the caches. Chrome's Application > Cache storage shows pages-v1 entries and their headers (including X-Cached-At); confirm keys are normalized and that no opaqueredirect entries exist.
  • bfcache. Chrome's Application > Back/forward cache panel runs a test navigation and lists blocking reasons, grouped as actionable, pending support and not actionable. Log notRestoredReasons from the field with the snippet above.
  • Speculation rules. Chrome's Application > Background services > Speculative loads panel lists rule sets, each speculation's status (not triggered, running, ready, failure) and the reason for failures. Watch for Sec-Purpose requests in your server logs to confirm speculation happens at all.
  • Cross-document View Transitions. The Animations panel captures and replays the transition; slow it down to see which elements morph.
  • Offline. Toggle Offline in the Network panel or in Application > Service workers, then navigate to a visited page, an unvisited page and a deep link; each should produce the expected cached page, offline page or rendered route.
  • Long-session memory. Record heap snapshots before and after cycling through routes, and filter for "Detached" nodes.

More detail on each panel is in Browser DevTools, and end-to-end tests for both models (offline navigations, update flows, speculation) are covered in Automated Testing.

Further reading

On this site

External references