Skip to content

Precaching and runtime caching

Precaching means downloading a known, versioned list of files into Cache Storage while the service worker installs, so they are available before the worker controls a single page. Runtime caching means storing responses as the app requests them, according to rules you define per route. Almost every production PWA uses both. Precaching gives you a guaranteed, atomic offline baseline (the app shell, the offline page, core scripts). Runtime caching covers everything you can't or shouldn't enumerate at build time: API data, user content, images, third-party resources. This page covers the mechanics of both, down to the spec algorithms, the build tooling, and the failure modes.

Key takeaways

  • Precache only what every user needs to boot the app offline. Everything else belongs in runtime caches with explicit expiration. Each precached byte is downloaded on the first visit, whether or not the user ever needs it.
  • A precache manifest pairs every URL with a revision (a content hash). File names that already contain a hash need no revision. Inline the manifest into sw.js: its bytes then change on every deploy, and that is what triggers the browser's update check.
  • cache.addAll() is all-or-nothing. Any non-2xx response, 206, Vary: *, or a network error rejects the whole batch and rolls it back. Use cache: "reload" for unhashed URLs so a stale HTTP-cache copy never gets precached.
  • Keying cache entries by revision (Workbox's __WB_REVISION__ approach) lets a new worker download only the files that changed. The old worker keeps serving its own entries until the new one activates.
  • Clean up in activate, never in install, and only touch caches that belong to your scope. CacheStorage is shared by every service worker on the origin.
  • An SPA navigation fallback needs a denylist. Without one, index.html answers requests for /api/*, OAuth callbacks, sitemaps and every other non-app URL inside the scope.

Precaching vs runtime caching

Both techniques store Request/Response pairs in the same Cache Storage API. They differ in when the entries are written, who decides which entries exist, and how they are versioned.

Precaching Runtime caching
When entries are written During the install event, before the worker can control clients During fetch events (or on demand from a page), after the worker is active
Who decides membership The build: a manifest of URLs generated from your output directory Routing rules evaluated per request (URL pattern, request.destination, method)
Versioning Per-entry revision hash; the whole set changes atomically with the worker Per-cache name or per-entry age; entries replaced individually
Update trigger A new service worker version (byte-different sw.js) The caching strategy itself (stale-while-revalidate, network-first, expiration)
Failure mode A single failed download fails the entire install; the old worker stays in control A failed request only affects that request
First-visit cost Everything in the manifest is downloaded on the first visit Only what the user actually requests
Consistency guarantee All files come from the same build Entries can come from different deployments
Typical contents App shell HTML, core JS/CSS, fonts, offline page, fallback image API responses, article pages, images, avatars, third-party scripts
Growth Bounded by the build Unbounded unless you add expiration (entry count, age, quota)

The core reason precaching exists is consistency. Your JavaScript bundle, CSS and HTML shell are built together and are only guaranteed to work together. If one of them came from deployment N and another from deployment N+1, the app could break in ways that are very hard to debug. Precaching ties the entire set to one service worker version. The browser only activates that version once every file is stored, and it swaps versions in a single step. The service worker lifecycle is designed around this guarantee: the waiting phase exists so that a new precache never gets mixed with pages still running the old one.

Runtime caching trades that guarantee for flexibility. The caching strategies page covers the strategies themselves (cache-first, network-first, stale-while-revalidate and variants). This page covers how runtime routes coexist with a precache.

sequenceDiagram
    participant Page
    participant SW as Service worker
    participant PC as Precache
    participant RC as Runtime caches
    participant Net as Network
    Note over SW,PC: install event
    SW->>Net: fetch every manifest URL
    Net-->>SW: responses
    SW->>PC: put (all or nothing)
    Note over SW: activate, then controls clients
    Page->>SW: GET /app.3f9a.js
    SW->>PC: match
    PC-->>SW: hit
    SW-->>Page: response (no network)
    Page->>SW: GET /api/feed
    SW->>Net: fetch (network-first)
    Net-->>SW: 200
    SW->>RC: put copy
    SW-->>Page: response

What belongs in the precache

Apply one test to every candidate: will nearly every user need this file to use the app offline, and does it change only when you deploy? If both answers are yes, precache it. If not, cache it at runtime.

Asset type Precache? Reasoning
App shell HTML (index.html for an SPA) Yes Required to render anything offline. Small.
Core JS and CSS bundles Yes Required by the shell. Hashed file names make them cheap to version.
Lazy-loaded route chunks Usually yes for small apps, selectively for large ones After a deploy, old chunks may disappear from the server. A precached chunk is immune to that.
Offline fallback page and its assets Yes It must exist before the first failure. It can't be fetched when the network is down.
Fallback image (SVG placeholder) Yes Tiny, and needed exactly when the network is unavailable.
Web fonts (woff2) used by the shell Usually Only the subsets and weights the shell actually renders.
Icons referenced by the manifest One or two sizes at most The browser fetches install icons itself. Precaching all 12 sizes wastes bandwidth.
Responsive image sets (srcset) No Each device needs one candidate. Precaching all of them multiplies the cost.
Polyfills No Most modern browsers never request them.
Every page of a multi-page site No Precached HTML is frozen until the next service worker update. Use network-first at runtime.
API responses, user data No Dynamic and user-specific. Use runtime caching or IndexedDB.
Large media (video, audio, maps) No Use on-demand "save for offline", or Background Fetch on Chromium.
Third-party scripts No You don't control their versioning. addAll() also can't store opaque responses (see below).

The Workbox precaching dos and don'ts guidance reaches the same conclusions. The Chrome team specifically warns against precaching responsive images, favicon sets, polyfills and all static HTML files. Its summary: "It's easy to precache too much and potentially waste data and bandwidth."

The hybrid model most PWAs use

A typical production configuration looks like this:

  1. Precache the shell, the core bundles, the offline page, and one fallback image, with a total budget of a few hundred kilobytes compressed.
  2. Runtime-cache hashed assets not in the precache (lazy chunks, route-specific CSS) with cache-first, because a hashed URL never changes meaning.
  3. Runtime-cache navigations (for multi-page apps) and API calls with network-first plus a timeout, falling back to cached data.
  4. Runtime-cache images with cache-first plus an expiration policy (entry count and max age).
  5. Save on demand whatever the user explicitly asks to keep offline, in a separate cache that the precache cleanup never touches.

The rest of this page builds each layer. The App Shell Model page covers the performance side of step 1. Offline UX & Fallbacks covers what users see when every layer misses.

The precache manifest

A precache manifest is the build-generated list of files the service worker must store during installation. It is the contract between your build and your worker.

Anatomy of a manifest entry

Workbox made the de facto format. Other tools (and the generator later on this page) use the same shape:

precache manifest (injected into sw.js)
[
  { url: "/index.html", revision: "a3f9c1e07b2d4e11" },       // (1)!
  { url: "/offline.html", revision: "0c7d1f9a2e3b4c5d" },
  { url: "/assets/app-DiwrgTda.js", revision: null },         // (2)!
  { url: "/assets/app-Bq8x1LmN.css", revision: null },
  {
    url: "/fonts/inter-latin-400.woff2",
    revision: "9b1e44c2aa0d7f3e",
    integrity: "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC" // (3)!
  }
]
  1. An unhashed URL needs a revision. The revision is a hash of the file's content, so it changes exactly when the bytes change.
  2. A URL that already contains a content hash is its own version. revision: null tells the worker that the URL alone identifies the content, so the HTTP cache may be used when fetching it.
  3. Optional Subresource Integrity metadata. It's passed to fetch() so a corrupted or tampered response fails the install instead of being stored.

The fields mean the following:

url
The URL to fetch and later serve, resolved against the service worker's location. Use root-relative or absolute URLs. Relative URLs resolve against the worker's script URL, not the page.
revision
An opaque version string. The worker combines it with the URL to form the cache key. Two manifests with the same url and revision refer to the same bytes. If the revision is null or absent, the URL is assumed to be versioned already.
integrity
An SRI string (sha256-, sha384- or sha512- followed by a base64 digest). The fetch fails with a network error if the body doesn't match. It only works when the response is readable by the worker, which is always true for same-origin requests and requires CORS for cross-origin ones.

Content hashes vs build IDs vs timestamps

The revision's only job is to change when, and only when, the file's bytes change. The obvious options differ a lot in practice:

Revision source Changes when content changes Changes when content doesn't Effect on repeat visitors
Content hash (MD5, SHA-256) Yes No Only modified files are re-downloaded
Build ID / git SHA Yes Yes, on every build Every file is re-downloaded on every deploy
File mtime Usually Yes, whenever CI checks out or copies files Unpredictable; often re-downloads everything
Semantic app version Only if you remember to bump it No Stale files served after a forgotten bump

Use content hashes. Workbox's build tools use MD5, which is fine here: the hash detects changes, it is not a security boundary. The generator later on this page uses SHA-256 truncated to 64 bits (16 hex characters), which is more than enough to make accidental collisions irrelevant.

Hash the bytes you actually serve

Compute revisions from the final build output, after minification, template rendering and any post-processing. Don't compute them from source files. If a CDN or edge function rewrites HTML on the way out (injecting scripts, obfuscating email addresses, rewriting links), the bytes the worker stores differ from the bytes you hashed. That's harmless for plain revisions. It breaks SRI integrity checks, and the install fails.

Hashed file names need no revision

Bundlers such as Vite, webpack, Rollup and esbuild can put a content hash in the output file name (app-DiwrgTda.js, main.3f9a1c2b.css). A new build that changes the file produces a new URL, so the URL itself is the version. For these entries, use revision: null. This has two concrete benefits:

  1. No cache-busting on install. The worker can fetch the file with the default cache mode. If the page already downloaded app-DiwrgTda.js into the HTTP cache moments earlier, the install reuses those bytes instead of downloading them again (see first-visit cost).
  2. A stable cache key. Nothing to append or strip. The key is the URL.

Workbox's dontCacheBustURLsMatching build option exists for exactly this case. Files whose URL matches the regular expression get revision: null.

Why the manifest must live inside the service worker file

The browser decides whether a new service worker exists by fetching the registered script and comparing it byte for byte with the installed one. If the bytes are identical, nothing happens. No install event fires, so no new precache is downloaded. Two consequences follow:

  • The manifest must be part of the bytes being compared. If your worker loaded the manifest with fetch("/precache-manifest.json") at install time, a deploy that only changed CSS would leave sw.js untouched. The browser would never install a new worker, and users would stay on the old precache indefinitely.
  • importScripts() files are compared too, in modern browsers. Chrome 68 stopped using the HTTP cache for update checks of the main script by default. Chrome 78 extended the byte-for-byte comparison to scripts loaded with importScripts(). The Chrome team notes that Firefox had matched this since Firefox 56, and that Safari already implemented it (source). An imported precache-manifest.<hash>.js therefore works. Inlining is still simpler and avoids an extra request on every update check.

Inlining is what Workbox's injectManifest does. It replaces the literal string self.__WB_MANIFEST in your source worker with the JSON array. The Updating Service Workers page covers the update check in detail, including updateViaCache.

Deterministic manifests

Make the generated manifest deterministic: sort entries by URL, and don't embed timestamps or build IDs. If two builds of the same source produce different sw.js bytes, every deploy installs a new worker. The new worker then re-checks every entry. The downloads are skipped because the cache keys match, but the update prompts still appear, and your "new version available" UI fires for nothing.

Install-time caching with cache.addAll()

The simplest precache is a single call to cache.addAll() inside install. Before building anything more elaborate, you need to know exactly what that call does.

What addAll() does, step by step

The Service Worker specification defines addAll(requests) roughly as follows (paraphrased and condensed):

  1. For each input that is already a Request object, if its URL scheme isn't http or https, or its method isn't GET, return a promise rejected with a TypeError.
  2. For each input, construct a Request (strings become new Request(url), which means mode cors and credentials same-origin). If that throws, reject with the exception. If the scheme isn't http(s), abort any fetches already started and reject with a TypeError.
  3. If called from a service worker, set the request's service-workers mode to "none". A worker's own fetches never pass through a fetch event anyway, so this makes it explicit.
  4. Start all fetches in parallel. For each response:
    • If the response's type is "error" (a network failure), or its status is not an ok status (200–299), or its status is 206, reject with a TypeError.
    • If the response has a Vary header containing *, reject with a TypeError and abort all other in-flight fetches.
    • Otherwise, wait until the entire body has been received. The spec notes that "the cache commit is allowed when the response's body is fully received". If the body is aborted, reject with an AbortError.
  5. Wait for all responses. Then build a list of put operations, one per request, and run Batch Cache Operations.
  6. Batch Cache Operations runs atomically. It copies the current cache as a backup, then applies each put. If two operations in the same batch match each other (the same URL twice), it throws an InvalidStateError. If a write exceeds the quota, it throws a QuotaExceededError. On any exception, the cache is restored from the backup: the spec says the implementation "does undo (roll back) any changes made to the cache storage during the batch operation job."

add(request) is literally addAll([request]) with the result discarded.

Every way addAll() can reject

Condition Error Typical cause
URL scheme not http/https TypeError A chrome-extension:, data: or blob: URL in the list
Method not GET TypeError Passing a Request constructed with method: "POST"
Network error TypeError Offline, DNS failure, CORS failure on a cross-origin URL, blocked by CSP connect-src
Status outside 200–299 TypeError A typo in a URL (404), a missing file after a deploy, a redirect to a login page that ends in 401/403
Status 206 Partial Content TypeError The server answered a range request (rare for addAll, common with media)
Vary: * TypeError A misconfigured framework or proxy
Opaque response (cross-origin, no-cors) TypeError Status of an opaque response is 0, which isn't an ok status
Duplicate request in the same batch InvalidStateError The same URL listed twice (for example, / and / after normalization)
Quota exceeded QuotaExceededError Precache plus existing data exceeds the origin's quota
Body stream aborted AbortError Connection dropped mid-download

Two consequences matter for manifest design. First, cross-origin resources are hard to precache with addAll(). A string URL creates a cors-mode request, so the third-party server must send Access-Control-Allow-Origin. If you use no-cors to get around that, the opaque response has status 0 and is rejected anyway. Second, the manifest must be deduplicated. Two entries that normalize to the same URL fail the whole install with InvalidStateError.

Browser error messages for these failures are unhelpful. Chromium reports TypeError: Failed to execute 'addAll' on 'Cache': Request failed, without telling you which URL failed. For debugging, fetch each URL individually and log the failures, as the incremental implementation below does.

Bypassing the HTTP cache with cache: "reload"

The service worker's fetch() still goes through the browser's HTTP cache. If /index.html is served with Cache-Control: max-age=3600, and the browser fetched it 10 minutes before the new worker installs, addAll(["/index.html"]) stores the old HTML from the HTTP cache under the new revision. The worker is now permanently inconsistent: new JS, old HTML, until the next deploy.

Pass Request objects with an explicit cache mode to prevent this:

sw.js
const urls = ["/index.html", "/offline.html"];
await cache.addAll(urls.map((url) => new Request(url, { cache: "reload" })));

The cache modes relevant to precaching:

Mode Reads HTTP cache Writes HTTP cache Headers the browser adds Use for
default Yes, if fresh; revalidates if stale Yes Conditional headers when revalidating Hashed URLs (revision: null): a cached copy is guaranteed correct
reload No Yes Cache-Control: no-cache, Pragma: no-cache Unhashed URLs with a revision
no-cache Yes, but always revalidates Yes Cache-Control: max-age=0 (plus validators) Unhashed URLs, when a 304 should save bandwidth
no-store No No Cache-Control: no-cache, Pragma: no-cache One-off requests you never want in the HTTP cache

The header column comes from the Fetch Standard's HTTP-network-or-cache fetch algorithm. For reload and no-store, the browser appends Pragma: no-cache and Cache-Control: no-cache unless the request already has them. Those request headers ask intermediate caches (CDNs, proxies) to revalidate as well. Whether a CDN honors them depends on its configuration: many CDNs ignore client cache directives by default. If your CDN might hold a stale copy of an unhashed file after a deploy, purge it as part of the deploy. cache: "reload" only controls the browser's own HTTP cache.

no-cache is a legitimate alternative to reload. When the HTTP cache holds a copy with an ETag or Last-Modified, the server can answer 304 Not Modified with no body. Fetch turns that into a full 200 response from the HTTP cache, which addAll() accepts. Workbox uses reload for revisioned entries and default for unrevisioned ones.

Why not a cache-busting query parameter?

The original sw-precache library appended a parameter such as ?_sw-precache=<hash> to each request URL. That also defeats the HTTP cache, but it changes the URL, and that has side effects. CDN cache keys fragment, the origin sees unexpected query strings, and servers that reject unknown parameters return errors. cache: "reload" achieves the same result without changing the URL. Request.cache is supported in Chrome 64, Firefox 48, Safari 10.1 (iOS 10.3) and all later versions.

A minimal versioned precache

This is the smallest correct precache. Every deploy gets its own cache, named after a version string that the build stamps into the file:

sw.js (minimal versioned precache)
const VERSION = "2026-09-25.1"; // replaced by the build; any change makes sw.js byte-different
const PRECACHE = `precache-${VERSION}`;
const PRECACHE_URLS = ["/", "/index.html", "/offline.html", "/app.3f9a1c2b.js", "/app.b7e2d4f0.css"];

self.addEventListener("install", (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(PRECACHE);
      // "reload" so nothing stale from the HTTP cache is captured under the new version.
      await cache.addAll(PRECACHE_URLS.map((url) => new Request(url, { cache: "reload" })));
    })()
  );
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      // Delete every precache except this version's. Runtime caches use other prefixes.
      const names = await caches.keys();
      await Promise.all(
        names.filter((name) => name.startsWith("precache-") && name !== PRECACHE).map((name) => caches.delete(name))
      );
    })()
  );
});

self.addEventListener("fetch", (event) => {
  if (event.request.method !== "GET") return;
  event.respondWith(
    (async () => {
      const cached = await caches.match(event.request, { cacheName: PRECACHE });
      return cached ?? fetch(event.request);
    })()
  );
});

It works, and it's what many hand-written workers do (including this site's own worker). Its weakness is bandwidth. Every deploy re-downloads every file into a fresh cache, even if only one of them changed. For a 300 KB shell that's usually acceptable. For a 3 MB application with frequent deploys it isn't. The incremental precaching section fixes this.

Why versioned caches make installs atomic

Two properties of the lifecycle combine to make precaching safe:

  • event.waitUntil() gates installation. If the promise passed to waitUntil() in install rejects, installation fails. The new worker becomes redundant. The registration keeps its current active worker, and the browser retries on the next update check (the next navigation into scope, or an explicit registration.update()).
  • The old worker keeps serving its own cache. While the new worker installs and waits, the old worker still controls every open page, and it reads from precache-<old>. The new worker writes to precache-<new>. They never touch each other's entries.
sequenceDiagram
    participant Old as Active worker v1
    participant New as New worker v2
    participant C1 as precache-v1
    participant C2 as precache-v2
    Note over Old,C1: controls all open pages
    New->>C2: addAll(v2 manifest)
    alt any response fails
        C2-->>New: rejected, batch rolled back
        Note over New: install fails, v2 is redundant
        Note over Old,C1: keeps serving, retry on next update check
    else all responses ok
        C2-->>New: committed
        Note over New: installed (waiting)
        Note over Old: last client closes (or skipWaiting)
        New->>C1: activate deletes old precache
        Note over New,C2: now controls pages
    end

The combination gives you a strict guarantee: a worker only ever serves a complete precache from its own build. The lifecycle page explains why calling skipWaiting() weakens this guarantee. Pages loaded under v1 would suddenly be served v2 files, which is safe only if your HTML and assets tolerate being mixed. Updating Service Workers compares the ways to hand control to a new version safely.

What atomicity does not cover

  • Runtime caches are shared across versions. A images cache written by v1 is read by v2. That's usually what you want. It becomes a problem when the format of cached data changes, for example when a cached API response has a new shape. Version runtime cache names when the stored format changes (api-v3).
  • IndexedDB is not versioned with the worker. Schema migrations run through onupgradeneeded independently of the service worker lifecycle. See IndexedDB.
  • An empty cache can be left behind. caches.open(PRECACHE) creates the cache before addAll() runs. If the install then fails, an empty precache-<new> cache remains. It holds no data, and the next successful activation deletes it, because the cleanup removes every precache name except the current one.
  • Server-side availability is your responsibility. The atomic install protects the client. If your deploy removes app.3f9a1c2b.js from the server while clients running v1 still need a lazy chunk that v1 didn't precache, those clients fail. Keep previous deployments' hashed assets on the server for at least as long as old workers may run (days to weeks).

Incremental precaching: download only what changed

The fix for the bandwidth problem is to key every entry by its revision inside a single, long-lived precache. A new worker checks whether the cache already holds url + revision. If it does, the download is skipped. That is Workbox's design, and this section implements it without Workbox so you can see every moving part.

Cache keys look like this:

Manifest entry Cache key
{ url: "/index.html", revision: "a3f9c1e07b2d4e11" } https://app.example/index.html?__rev=a3f9c1e07b2d4e11
{ url: "/assets/app-DiwrgTda.js", revision: null } https://app.example/assets/app-DiwrgTda.js

During the transition, the old and new workers share the precache:

Moment Entries in the precache Who reads them
v1 active v1 keys v1
v2 installing v1 keys + new v2 keys (unchanged files share keys) v1 reads v1 keys; v2 writes
v2 waiting v1 keys + v2 keys v1
v2 activates v2 activate deletes keys not in the v2 manifest v2

The old worker never loses an entry it needs, because deletion only happens after it has been replaced. Unchanged files are downloaded once in the app's lifetime.

A complete incremental precaching service worker

The following worker is production-ready. It includes incremental precaching, bounded parallelism, redirect handling, precise cleanup, Workbox-compatible URL matching (directory index, clean URLs, ignored query parameters), and an SPA navigation fallback with an allowlist and a denylist. The self.__PRECACHE_MANIFEST placeholder is replaced at build time by the generator in the next section but one.

src/sw.js
/*
 * Incremental precaching service worker.
 * The build step replaces the injection point below with the manifest array.
 */
"use strict";

const PRECACHE_MANIFEST = self.__PRECACHE_MANIFEST;

// ---- Configuration --------------------------------------------------------

const CACHE_PREFIX = "app";
const SCOPE = self.registration.scope; // e.g. "https://app.example/"
// Include the scope: CacheStorage is per ORIGIN, shared by every worker on it.
const PRECACHE_NAME = `${CACHE_PREFIX}-precache-v1-${SCOPE}`;
const REVISION_PARAM = "__rev";
const INSTALL_CONCURRENCY = 4;

const DIRECTORY_INDEX = "index.html";
const IGNORED_SEARCH_PARAMS = [/^utm_/, /^fbclid$/];

// SPA navigation fallback. Set SHELL_URL to null for a multi-page site.
const SHELL_URL = new URL("index.html", SCOPE).href;
const NAVIGATION_ALLOWLIST = [/./];
const NAVIGATION_DENYLIST = [
  /^\/api\//, // JSON endpoints opened directly in a tab
  /^\/auth\//, // OAuth / OIDC callbacks must reach the server
  /^\/admin(\/|$)/, // server-rendered area inside the same scope
  /\/[^/?]+\.[^/?]+(\?.*)?$/, // "file-like" paths: /sitemap.xml, /report.pdf
];

// ---- Manifest processing (runs once per worker startup) -------------------

/** @type {Map<string, {cacheKey: string, revision: string|null, integrity?: string}>} */
const entriesByURL = new Map();

for (const raw of PRECACHE_MANIFEST) {
  const entry = typeof raw === "string" ? { url: raw, revision: null } : raw;
  const url = new URL(entry.url, self.location.href);
  url.hash = "";
  const cacheKey = new URL(url.href);
  if (entry.revision) cacheKey.searchParams.set(REVISION_PARAM, entry.revision);

  const existing = entriesByURL.get(url.href);
  if (existing && existing.cacheKey !== cacheKey.href) {
    // Same URL with two revisions: a build bug. Fail loudly at startup.
    throw new Error(`Conflicting precache entries for ${url.href}`);
  }
  entriesByURL.set(url.href, {
    cacheKey: cacheKey.href,
    revision: entry.revision ?? null,
    integrity: entry.integrity,
  });
}

const expectedCacheKeys = new Set([...entriesByURL.values()].map((e) => e.cacheKey));

// ---- Install: download only what the cache doesn't already have -----------

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

async function precacheAll() {
  const cache = await caches.open(PRECACHE_NAME);
  const queue = [...entriesByURL.entries()];
  const report = { downloaded: [], reused: [] };

  async function worker() {
    while (queue.length > 0) {
      const [url, entry] = queue.shift();
      if (await cache.match(entry.cacheKey)) {
        report.reused.push(url);
        continue;
      }
      const request = new Request(url, {
        // Revisioned (unhashed) URLs must bypass the HTTP cache; hashed ones may use it.
        cache: entry.revision ? "reload" : "default",
        credentials: "same-origin",
        integrity: entry.integrity, // undefined means "no integrity check"
      });
      let response;
      try {
        response = await fetch(request);
      } catch (error) {
        throw new Error(`Precache network error for ${url}: ${error.message}`);
      }
      if (!response.ok) {
        throw new Error(`Precache got HTTP ${response.status} for ${url}`);
      }
      if (response.redirected) {
        response = await withoutRedirectFlag(response);
      }
      await cache.put(entry.cacheKey, response);
      report.downloaded.push(url);
    }
  }

  const workers = Math.min(INSTALL_CONCURRENCY, queue.length);
  await Promise.all(Array.from({ length: workers }, worker));
  console.info(
    `[precache] ${report.downloaded.length} downloaded, ${report.reused.length} reused`
  );
}

/**
 * A response that followed a redirect keeps its URL list. Serving it for a
 * navigation (redirect mode "manual") is a network error per the Fetch spec,
 * so copy the body and headers into a fresh Response without that history.
 */
async function withoutRedirectFlag(response) {
  const body = await response.blob();
  return new Response(body, {
    status: response.status,
    statusText: response.statusText,
    headers: response.headers,
  });
}

// ---- Activate: delete entries and caches this version doesn't use -----------

self.addEventListener("activate", (event) => {
  event.waitUntil(cleanupPrecache());
});

async function cleanupPrecache() {
  const cache = await caches.open(PRECACHE_NAME);
  const keys = await cache.keys();
  await Promise.all(
    keys.filter((request) => !expectedCacheKeys.has(request.url)).map((request) => cache.delete(request))
  );

  // Remove precaches from older naming schemes, for THIS scope only.
  const names = await caches.keys();
  await Promise.all(
    names
      .filter(
        (name) =>
          name.startsWith(`${CACHE_PREFIX}-precache-`) &&
          name.endsWith(SCOPE) &&
          name !== PRECACHE_NAME
      )
      .map((name) => caches.delete(name))
  );
}

// ---- Fetch: serve precached URLs, then the SPA shell for navigations ------

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;

  const cacheKey = findCacheKey(url);
  if (cacheKey) {
    event.respondWith(fromPrecache(request, cacheKey));
    return;
  }

  if (SHELL_URL && request.mode === "navigate" && isAppNavigation(url)) {
    event.respondWith(serveShell(request));
  }
  // Anything else falls through to runtime-caching listeners or the network.
});

/** Mirror Workbox's PrecacheRoute: exact URL, ignored params removed, directory index, clean URL. */
function findCacheKey(url) {
  for (const candidate of urlVariations(url)) {
    const entry = entriesByURL.get(candidate);
    if (entry) return entry.cacheKey;
  }
  return null;
}

function* urlVariations(original) {
  const url = new URL(original.href);
  url.hash = "";
  yield url.href;

  for (const name of [...url.searchParams.keys()]) {
    if (IGNORED_SEARCH_PARAMS.some((re) => re.test(name))) url.searchParams.delete(name);
  }
  yield url.href;

  if (url.pathname.endsWith("/")) {
    const withIndex = new URL(url.href);
    withIndex.pathname += DIRECTORY_INDEX;
    yield withIndex.href;
  } else {
    const clean = new URL(url.href);
    clean.pathname += ".html"; // "/about" -> "/about.html"
    yield clean.href;
  }
}

async function fromPrecache(request, cacheKey) {
  const cache = await caches.open(PRECACHE_NAME);
  const cached = await cache.match(cacheKey);
  if (cached) return cached;
  // Entry missing (for example, deleted in DevTools): fall back to the network.
  return fetch(request);
}

function isAppNavigation(url) {
  const target = url.pathname + url.search;
  if (NAVIGATION_DENYLIST.some((re) => re.test(target))) return false;
  return NAVIGATION_ALLOWLIST.some((re) => re.test(target));
}

async function serveShell(request) {
  const cacheKey = entriesByURL.get(SHELL_URL)?.cacheKey;
  if (cacheKey) {
    const cache = await caches.open(PRECACHE_NAME);
    const shell = await cache.match(cacheKey);
    if (shell) return shell;
  }
  try {
    return await fetch(request);
  } catch {
    return Response.error();
  }
}

// ---- Update flow: let the page decide when to activate the new version -----

self.addEventListener("message", (event) => {
  if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
});

Several design decisions in this worker are not obvious:

Why a while loop over a shared queue instead of Promise.all(entries.map(...))? A 150-entry manifest fired in parallel opens 150 requests at once. Browsers queue them per host anyway (six connections per host on HTTP/1.1, stream limits on HTTP/2 and HTTP/3). But all of those responses are buffered at once, and a single failure only surfaces after every request has settled. Four workers keep memory bounded, surface the first failing URL quickly, and still saturate a typical connection. Workbox goes further and caches entries one at a time (issue #2528 explains why).

Why cache.put() per entry instead of one addAll()? The entry-by-entry approach gives up the batch rollback, and that's acceptable here. Every stored entry is keyed by its revision, so a partially completed install leaves only correct entries behind. The next install attempt reuses them. The atomicity that matters, "this worker only serves its own complete set", is still enforced: waitUntil() rejects if any entry fails, so the worker never activates with holes.

Why check response.ok manually? Unlike addAll(), cache.put() happily stores a 404 or 500. Precaching an error page under a revision would make the error permanent for that version.

Why include the scope in the cache name? CacheStorage belongs to the origin, not to a registration. If https://example.com/app/ and https://example.com/admin/ each register a worker, both see the same caches.keys(). A cleanup that deletes "everything that isn't mine" in one worker wipes the other worker's precache. Workbox appends registration.scope to its default cache names for the same reason. Its precache is named workbox-precache-v2-https://example.com/app/.

Redirected responses and navigation requests

The withoutRedirectFlag() step deserves a closer look. The Fetch Standard's handle fetch integration returns a network error if the service worker responds with a response whose URL list has more than one item (in other words, a response that followed a redirect) to a request whose redirect mode is not "follow". Navigation requests use redirect mode "manual". So if / is served as a 301 to /index.html, and you precache /, the stored response has redirected: true. Using it to answer a navigation shows the browser's network error page instead of your app.

Copying the body and headers into a new Response produces a response with a single-entry URL list. Workbox applies the same fix through its internal copyRedirectedCacheableResponsesPlugin, which calls copyResponse() for any response with redirected === true. Better still, precache the final URL (/index.html) and let directoryIndex map / onto it, so no redirect is involved at all.

Cleaning up outdated precache entries

Cleanup looks trivial, but it has three separate jobs, and each one has its own trap.

Job When What to delete Trap
Entry-level cleanup activate Keys in the precache that the current manifest doesn't list Running it in install deletes entries the still-active old worker is serving
Cache-level cleanup activate Whole caches from older naming schemes or library versions Deleting caches owned by other scopes, runtime caches, or "saved for offline" content
Runtime cache expiry Continuously Entries beyond a count, age, or quota Covered by expiration plugins or your own LRU logic

Always clean up in activate. At that point the previous worker has been replaced: no page is controlled by it anymore, and it will never handle another fetch. During install, the old worker is still serving every open page. If the new worker deletes index.html?__rev=OLD during install, the old worker's next navigation misses the cache and, if the device is offline, fails.

Match cache names positively, not negatively. "Delete every cache that isn't PRECACHE_NAME" deletes runtime caches, other scopes' caches, and the user's saved articles. Delete only names that match your precache prefix and your scope and are not the current name, as the worker above does.

Keep activate fast. Fetch events that arrive while the worker is activating are queued until activation completes. Deleting a handful of cache entries takes milliseconds. Don't start downloads or run migrations over large IndexedDB stores inside the activate handler's waitUntil().

Workbox's cleanupOutdatedCaches()

Workbox has two separate cleanup mechanisms, and people often confuse them:

  1. PrecacheController.activate(), registered automatically by precache() and precacheAndRoute(), performs entry-level cleanup. It opens the precache, lists its keys, and deletes every key that isn't in the current manifest's set of cache keys. You get this for free.
  2. cleanupOutdatedCaches() is opt-in. It adds another activate listener that deletes whole caches whose name contains the substring -precache- and contains the current registration.scope, excluding the current precache name. It exists to remove precaches left behind by older Workbox major versions, which used different, incompatible cache names and formats.

In generateSW builds, cleanupOutdatedCaches defaults to false. In injectManifest builds, you call it yourself. Call it in every Workbox-based worker unless you have a specific reason not to. The only risk is naming one of your own runtime caches something that contains -precache- (don't).

Precache size budgets and first-visit cost

Precaching makes the second visit fast and offline-capable. It pays for that with the first visit. Every manifest entry is downloaded when the worker installs, typically a few seconds after the first page load, whether or not the user ever navigates to the part of the app that needs it.

What the first visit actually downloads

On a first visit, the browser loads the page and its subresources normally, filling the HTTP cache. Then the page registers the service worker, which installs and precaches. Whether the precache downloads a file again depends on the cache mode:

Entry kind Install cache mode Page already loaded it? Precache download cost
Hashed URL (revision: null) default Yes, and it's fresh in the HTTP cache ~0 (served from the HTTP cache)
Hashed URL default No (lazy chunk not needed by the first page) Full size
Unhashed URL with revision reload Yes Full size again: reload bypasses the HTTP cache by design
Unhashed URL with revision reload No Full size

This is the "double download" cost of precaching. It's why hashed file names matter: with revision: null and long-lived Cache-Control: max-age=31536000, immutable headers, the precache mostly reuses what the page already downloaded. Only unhashed files (usually just the HTML shell, the offline page, and a few root-level files) are downloaded twice. The interaction between Cache Storage and HTTP caching headers is covered in HTTP Caching & Service Workers.

A worked example: a shell whose manifest totals 1.4 MB uncompressed. The first page load needs 600 KB of it. Of the total, 40 KB is unhashed (HTML, offline page). The precache adds roughly 800 KB of lazy chunks plus 40 KB of re-downloaded HTML, so about 840 KB on top of the page load, before compression. On a metered connection that's a real cost. If most visitors bounce after one page, it's pure waste.

Setting a budget

Treat the precache like a performance budget and enforce it in CI. The generator below fails the build when the budget is exceeded. Reasonable starting points, measured as compressed transfer size:

App type Precache budget (compressed) Notes
Content site / blog / docs 100–300 KB Shell CSS/JS, offline page, logo, one font. Pages at runtime.
Typical SPA 300 KB – 1 MB Shell plus critical route chunks. Lazy chunks at runtime or precached selectively.
Productivity app used daily (editor, email, chat) 1–5 MB Users install it and expect full offline operation. The cost is amortized over many sessions.
Offline-critical field app Whatever the offline job requires Consider explicit "download for offline" flows instead of silent precaching.

Alongside a total budget, set a per-file cap. Workbox's maximumFileSizeToCacheInBytes defaults to 2,097,152 bytes (2 MiB) and skips larger files, reporting them only as a build warning. That catches the accidental 40 MB video that matched a glob pattern. Also watch the entry count. Each entry is a separate request, and hundreds of tiny entries cost more in per-request overhead than their byte size suggests.

Measure on real installs

In Chrome DevTools, open Application › Cache storage after a fresh install and compare the precache's entry count and total size with your budget. Check the Network panel with the Service worker request filter to see which downloads came from the worker's install. A request served from the HTTP cache shows (disk cache) in the Size column.

Register the worker after the page has loaded

The service worker's install downloads compete for bandwidth with the page's own requests if they overlap. The standard mitigation is to register after the load event, so precaching starts once the first page is complete:

register-sw.js
if ("serviceWorker" in navigator) {
  window.addEventListener("load", () => {
    navigator.serviceWorker.register("/sw.js").catch((error) => {
      console.error("Service worker registration failed", error);
    });
  });
}

Registration & Scope covers the trade-offs of registration timing in more detail. If the load event fires late because of slow third-party scripts, you can register on DOMContentLoaded plus a short idle delay instead.

Tiered precaching: core now, the rest later

When one precache is too big for a first visit but too important to leave entirely to runtime caching, split it into tiers:

  • Core tier: installed with the worker, gated by waitUntil(). It contains the shell, the offline page, and the critical routes. If it fails, the worker doesn't activate.
  • Warm tier: downloaded after activation, outside the install gate, when the page tells the worker the time is right. It contains secondary route chunks and fonts for rarely used weights. Failures are ignored; entries get a second chance at runtime.

Trigger the warm tier from the page, not from activate. Fetch events wait while activate is running, and a long activate delays every request. A message event is extendable in a service worker, so event.waitUntil() keeps the worker alive for the duration of the download:

warm-cache.js
// After the app is interactive and idle, ask the worker to warm secondary assets.
async function requestWarmCache() {
  const registration = await navigator.serviceWorker.ready;
  const saveData = navigator.connection?.saveData === true; // Chromium only
  if (saveData) return; // respect the user's data-saver preference
  registration.active?.postMessage({ type: "WARM_CACHE" });
}

if ("requestIdleCallback" in window) {
  requestIdleCallback(() => requestWarmCache(), { timeout: 10_000 });
} else {
  setTimeout(requestWarmCache, 5_000);
}
sw.js (warm tier)
const WARM_CACHE = `${CACHE_PREFIX}-warm-v1-${SCOPE}`;
const WARM_URLS = self.__WARM_MANIFEST; // hashed URLs only, injected by the build

self.addEventListener("message", (event) => {
  if (event.data?.type !== "WARM_CACHE") return;
  event.waitUntil(
    (async () => {
      const cache = await caches.open(WARM_CACHE);
      for (const url of WARM_URLS) {
        if (await cache.match(url)) continue;
        try {
          const response = await fetch(url);
          if (response.ok) await cache.put(url, response);
        } catch {
          // Best effort: a miss here is simply fetched at runtime later.
        }
      }
      // Drop warm entries that the current build no longer references.
      const wanted = new Set(WARM_URLS.map((u) => new URL(u, self.location.href).href));
      for (const request of await cache.keys()) {
        if (!wanted.has(request.url)) await cache.delete(request);
      }
    })()
  );
});

Your runtime cache-first route for hashed assets should then also check the warm cache. Or, more simply, use the same cache name for the warm tier and the runtime hashed-asset cache.

Storage quotas and eviction

Precaches are rarely large enough to hit a quota on their own. On current browsers, an origin can generally use a large share of free disk space. WebKit, for example, documents origin quotas of up to 60% of total disk for browser apps since Safari 17 (WebKit blog). The real risk is eviction: under storage pressure, browsers delete whole origins' data on a least-recently-used basis unless the origin has persistent storage. Eviction is origin-wide, so the caches go together (and the service worker registration typically goes with them). The next visit then behaves like a first visit. Individual precache entries can also disappear when a user or a DevTools session deletes them selectively. The incremental worker above handles that case by falling back to the network for the missing entry. For per-browser limits, navigator.storage.estimate(), persist(), and Safari's seven-day cap on script-writable storage for websites that aren't on the Home Screen, see Storage Quotas & Persistence.

Writing your own precache manifest generator

Workbox's build tools are the default choice. Writing your own generator is still worthwhile when you want zero build dependencies, when your pipeline isn't Node-based (the logic ports directly to Python, Go or a shell script), or when you need policies Workbox doesn't enforce, such as a hard budget that fails CI. The complete script below walks the build output, hashes files, applies include and exclude rules, marks hashed file names as self-versioned, enforces per-file and total budgets (using brotli-compressed size as a proxy for transfer cost), and injects a deterministic manifest into the worker.

scripts/build-precache.mjs
#!/usr/bin/env node
/**
 * Generates a precache manifest from the build output and injects it into the
 * service worker. Zero dependencies; Node.js 18+.
 *
 *   node scripts/build-precache.mjs            # inject and report
 *   node scripts/build-precache.mjs --dry-run  # report only, write nothing
 */
import { createHash } from "node:crypto";
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { brotliCompressSync, constants as zlib } from "node:zlib";

// ---- Configuration --------------------------------------------------------

const config = {
  distDir: "dist",
  swSrc: "src/sw.js",
  swDest: "dist/sw.js",
  injectionPoint: "self.__PRECACHE_MANIFEST",
  basePath: "/", // URL prefix under which distDir is served

  // Paths are relative to distDir and always use "/" separators.
  include: [/\.(?:html|js|mjs|css|woff2|svg|webmanifest|wasm)$/i],
  exclude: [
    /^sw\.js$/, // never precache the worker itself
    /\.map$/, // source maps
    /(^|\/)\./, // dotfiles and dot-directories (.well-known, .DS_Store)
    /^_headers$|^_redirects$/, // host configuration files
    /^assets\/.*\.(?:png|jpe?g|webp|avif|gif|mp4|webm)$/i, // media: runtime-cache it
  ],

  // Files whose names already contain a content hash get revision: null.
  // Vite emits base64url hashes ("index-DiwrgTda.js") under assets/;
  // webpack's [contenthash] is hex ("main.3f9a1c2b.js").
  isHashed: (file) => file.startsWith("assets/") || /[.-][0-9a-f]{8,}\.[a-z0-9]+$/i.test(file),

  // Extra URLs that don't map 1:1 to a file, e.g. a server-rendered route.
  // revision may be a fixed string or derived from files via `revisionFrom`.
  additionalEntries: [
    // { url: "/app", revisionFrom: ["index.html"] },
  ],

  maxFileBytes: 2 * 1024 * 1024, // per-file cap (same default as Workbox)
  budgetBrotliBytes: 400 * 1024, // total precache budget, compressed estimate
  maxEntries: 150,
  integrity: false, // set true to emit SRI hashes (see the warning in the docs)
};

// ---- Helpers ----------------------------------------------------------------

const args = new Set(process.argv.slice(2));
const dryRun = args.has("--dry-run");

async function* walk(dir, root = dir) {
  const dirents = await readdir(dir, { withFileTypes: true });
  for (const dirent of dirents) {
    const full = path.join(dir, dirent.name);
    if (dirent.isDirectory()) {
      yield* walk(full, root);
    } else if (dirent.isFile()) {
      // Normalize Windows separators so URLs and patterns are platform-neutral.
      yield path.relative(root, full).split(path.sep).join("/");
    }
  }
}

function toURL(file) {
  // Encode each segment ("my file.js" -> "my%20file.js") but keep the slashes.
  const encoded = file.split("/").map(encodeURIComponent).join("/");
  return config.basePath.replace(/\/?$/, "/") + encoded;
}

const revisionOf = (buffer) => createHash("sha256").update(buffer).digest("hex").slice(0, 16);
const integrityOf = (buffer) => `sha384-${createHash("sha384").update(buffer).digest("base64")}`;

function brotliSize(buffer) {
  return brotliCompressSync(buffer, {
    params: { [zlib.BROTLI_PARAM_QUALITY]: 9 }, // close to typical CDN settings
  }).length;
}

const kb = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;

// ---- Main -------------------------------------------------------------------

async function main() {
  if (path.resolve(config.swSrc) === path.resolve(config.swDest)) {
    throw new Error("swSrc and swDest must differ, or the injection point is lost after one build.");
  }

  const errors = [];
  const warnings = [];
  const entries = [];

  for await (const file of walk(config.distDir)) {
    if (!config.include.some((re) => re.test(file))) continue;
    if (config.exclude.some((re) => re.test(file))) continue;

    const full = path.join(config.distDir, file);
    const { size } = await stat(full);
    if (size > config.maxFileBytes) {
      errors.push(`${file} is ${kb(size)}; larger than maxFileBytes (${kb(config.maxFileBytes)}).`);
      continue;
    }

    const buffer = await readFile(full);
    const entry = {
      url: toURL(file),
      revision: config.isHashed(file) ? null : revisionOf(buffer),
    };
    if (config.integrity) entry.integrity = integrityOf(buffer);
    entries.push({ entry, file, size, brotli: brotliSize(buffer) });
  }

  for (const extra of config.additionalEntries) {
    let revision = extra.revision ?? null;
    if (extra.revisionFrom) {
      const hash = createHash("sha256");
      for (const file of extra.revisionFrom) hash.update(await readFile(path.join(config.distDir, file)));
      revision = hash.digest("hex").slice(0, 16);
    }
    entries.push({ entry: { url: extra.url, revision }, file: "(additional)", size: 0, brotli: 0 });
  }

  // Deterministic output: identical input must produce an identical sw.js.
  entries.sort((a, b) => (a.entry.url < b.entry.url ? -1 : a.entry.url > b.entry.url ? 1 : 0));

  // Duplicate URLs would make cache.addAll() throw InvalidStateError at runtime.
  const seen = new Set();
  for (const { entry } of entries) {
    if (seen.has(entry.url)) errors.push(`Duplicate precache URL: ${entry.url}`);
    seen.add(entry.url);
  }

  const totalRaw = entries.reduce((sum, e) => sum + e.size, 0);
  const totalBrotli = entries.reduce((sum, e) => sum + e.brotli, 0);
  if (totalBrotli > config.budgetBrotliBytes) {
    errors.push(`Precache is ${kb(totalBrotli)} (brotli), over the ${kb(config.budgetBrotliBytes)} budget.`);
  }
  if (entries.length > config.maxEntries) {
    warnings.push(`${entries.length} entries exceeds maxEntries (${config.maxEntries}).`);
  }

  // ---- Report ----
  console.log(`Precache: ${entries.length} entries, ${kb(totalRaw)} raw, ~${kb(totalBrotli)} brotli`);
  const largest = [...entries].sort((a, b) => b.brotli - a.brotli).slice(0, 10);
  for (const e of largest) {
    const kind = e.entry.revision === null ? "hashed" : `rev ${e.entry.revision}`;
    console.log(`  ${kb(e.brotli).padStart(10)}  ${e.entry.url}  (${kind})`);
  }
  for (const w of warnings) console.warn(`warning: ${w}`);
  if (errors.length > 0) {
    for (const e of errors) console.error(`error: ${e}`);
    process.exitCode = 1;
    return;
  }

  // ---- Inject ----
  const source = await readFile(config.swSrc, "utf8");
  const occurrences = source.split(config.injectionPoint).length - 1;
  if (occurrences !== 1) {
    throw new Error(
      `Expected exactly one "${config.injectionPoint}" in ${config.swSrc}, found ${occurrences}. ` +
        "Occurrences inside comments count too."
    );
  }
  const manifest = JSON.stringify(entries.map((e) => e.entry), null, 2);
  const output = source.replace(config.injectionPoint, () => manifest); // fn: no "$&" surprises

  if (dryRun) {
    console.log("Dry run: sw.js not written.");
    return;
  }
  await writeFile(config.swDest, output);
  console.log(`Wrote ${config.swDest}`);
}

main().catch((error) => {
  console.error(error instanceof Error ? error.message : error);
  process.exitCode = 1;
});

Wire it into the build so it always runs after the bundler, and so a budget violation fails the pipeline:

package.json (excerpt)
{
  "scripts": {
    "build": "vite build && node scripts/build-precache.mjs",
    "precache:report": "node scripts/build-precache.mjs --dry-run"
  }
}

A typical run prints something like this:

build output
Precache: 14 entries, 612.4 KB raw, ~171.9 KB brotli
      96.3 KB  /assets/index-DiwrgTda.js  (hashed)
      31.0 KB  /assets/editor-C8f2kQpZ.js  (hashed)
      17.2 KB  /fonts/inter-latin-400.woff2  (rev 9b1e44c2aa0d7f3e)
       9.8 KB  /assets/index-Bq8x1LmN.css  (hashed)
       2.1 KB  /index.html  (rev a3f9c1e07b2d4e11)
       1.4 KB  /offline.html  (rev 0c7d1f9a2e3b4c5d)
Wrote dist/sw.js

Design decisions in the generator

Why the brotli estimate? Budgets in raw bytes mislead: a 300 KB JavaScript bundle transfers as roughly 80–100 KB, while a 100 KB woff2 font is already compressed and transfers as roughly 100 KB. The worker's install fetches are compressed on the wire exactly like page fetches, so a compressed estimate is the number that matches user cost. Quality 9 is close to what many CDNs use for on-the-fly compression. If you pre-compress at quality 11, the real transfer is a little smaller.

Why fail on oversized files instead of skipping them? Workbox skips files over maximumFileSizeToCacheInBytes and emits a warning. A silently skipped file is a silent offline bug when that file is required. Failing forces an explicit decision: raise the cap, or move the file to runtime caching.

Why split(injectionPoint).length - 1? It counts every occurrence, including occurrences inside comments. Workbox's injectManifest behaves the same way: it matches the injection point with a global regular expression over the whole file and throws a "multiple injection points" error if it finds more than one. A comment such as // the manifest comes from self.__WB_MANIFEST is enough to break a Workbox build.

Why a replacer function in source.replace()? When the second argument is a string, String.prototype.replace() interprets $&, $', $` and $1 specially. A manifest containing a URL with $' in it would corrupt the output. A function's return value is inserted literally.

Why refuse swSrc === swDest? After the first build the injection point is gone (replaced by the array), so a second build would find zero occurrences. Workbox reports a dedicated same-src-and-dest error in exactly that situation: when it can't find the injection point and the source and destination paths resolve to the same file.

Why is SRI off by default? integrity makes the install fail if a single byte differs between build and delivery. That's the point of it, and it's valuable when you serve precached files from a CDN you don't fully control. But anything that legitimately rewrites content in flight breaks installs: an edge function that injects a script, an HTML minifier at the CDN, or a proxy that modifies responses. Turn it on only when you control the whole delivery path, and when you do, keep a monitoring signal for failed installs.

What about index.html served at /? The generator emits /index.html. The worker's directory-index logic maps a request for / onto it. Don't add / as a separate entry unless the server renders / differently from /index.html. If it does, use additionalEntries with revisionFrom listing the files that affect the rendered output.

Workbox precaching

Workbox is the most widely used service worker library. Its workbox-precaching module implements everything in the previous sections. At the time of writing, the current release is Workbox 7.4.1 (published to npm in May 2026). Its build packages (workbox-build, workbox-cli) declare Node.js 20 or later since 7.4.0; releases 7.0 to 7.3 required Node.js 16. The runtime modules are plain JavaScript that runs in any browser with service worker support.

How Workbox stores precached responses

Reading workbox-precaching's source explains most of its behavior:

  • Cache name. workbox-precache-v2-<registration.scope>, for example workbox-precache-v2-https://app.example/. The prefix (workbox), the precache segment (precache-v2), and the suffix (the scope) can be changed with setCacheNameDetails() from workbox-core.
  • Cache keys. An entry with a revision is stored under its URL with a __WB_REVISION__=<revision> query parameter appended. An entry without a revision (a plain string, or revision: null) is stored under its URL as-is. Two entries with the same URL but different revisions throw add-to-cache-list-conflicting-entries.
  • Install cache mode. Workbox sets cache: "reload" for entries with a truthy revision and cache: "default" for the rest. Requests use credentials: "same-origin" and pass the entry's integrity if present.
  • Install order. Entries are processed one at a time, in manifest order. Before fetching, Workbox checks whether the cache key already exists, and skips the download if it does. The install result reports updatedURLs and notUpdatedURLs, logged in development builds.
  • Cacheability check. Unless you add your own cacheWillUpdate plugin, a default plugin rejects responses with status 400 or higher, and the install fails with bad-precaching-response. The check only looks at the status code. Because Workbox builds its install requests with the Request constructor's default cors mode, a cross-origin entry served without CORS headers still fails the install, with a network error rather than an opaque response.
  • Redirects. A built-in plugin copies any response with redirected === true into a new Response, for the navigation reason described earlier.
  • Activation. PrecacheController.activate() deletes every key in the precache that isn't in the current manifest.
  • Missing entries at runtime. With the default fallbackToNetwork: true, a precache miss fetches from the network. If the manifest entry has integrity, and the request isn't no-cors, the network response "repairs" the precache.
  • Warning for unversioned entries. Passing a plain string, or an object whose revision is undefined, logs a console warning ("Workbox is precaching URLs without revision info"), even in production builds. An explicit revision: null does not warn: it is how the build tools mark hashed file names as self-versioned. Fix the warning by giving every unhashed entry a revision, not by hiding the log.

precacheAndRoute() and self.__WB_MANIFEST

In an injectManifest setup, you write the worker and Workbox's build step replaces the injection point:

src/sw.js (Workbox, bundled with Vite/Rollup/webpack)
import {
  cleanupOutdatedCaches,
  createHandlerBoundToURL,
  precacheAndRoute,
} from "workbox-precaching";
import { NavigationRoute, registerRoute } from "workbox-routing";

// Adds install/activate listeners and a fetch route for every precached URL.
precacheAndRoute(self.__WB_MANIFEST, {
  directoryIndex: "index.html", // "/docs/" also matches "/docs/index.html"
  cleanURLs: true, // "/about" also matches "/about.html"
  ignoreURLParametersMatching: [/^utm_/, /^fbclid$/, /^ref$/],
  urlManipulation: ({ url }) => {
    // Extra candidates, as URL objects: map "/app/anything" to "/app/".
    if (url.pathname.startsWith("/app/")) return [new URL("/app/", url)];
    return [];
  },
});

// Remove precaches created by older Workbox versions for this scope.
cleanupOutdatedCaches();

// SPA shell for navigations, excluding URLs the server must handle.
registerRoute(
  new NavigationRoute(createHandlerBoundToURL("/index.html"), {
    allowlist: [/^\/(?!api\/|auth\/)/],
    denylist: [/\/[^/?]+\.[^/?]+$/, /^\/admin(\/|$)/],
  })
);

self.addEventListener("message", (event) => {
  if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
});

precacheAndRoute(entries, options) is exactly precache(entries) followed by addRoute(options). Use the two functions separately when you need to register other routes before the precache route. Workbox's router evaluates routes in registration order and uses the first match. Other exports you will use:

Export Signature What it does
precache (entries: Array<string \| PrecacheEntry>) => void Adds entries and registers install/activate listeners once. Can be called repeatedly.
addRoute (options?: PrecacheRouteOptions) => void Registers a PrecacheRoute that answers requests for precached URLs.
precacheAndRoute (entries, options?) => void Both of the above.
cleanupOutdatedCaches () => void Adds an activate listener that deletes old -precache- caches for this scope.
createHandlerBoundToURL (url: string) => RouteHandlerCallback A handler that always answers with the precached url. Throws non-precached-url if url isn't in the manifest.
matchPrecache (request: string \| Request) => Promise<Response \| undefined> cache.match() that understands revision keys: matchPrecache("/offline.html").
getCacheKeyForURL (url: string) => string \| undefined The versioned cache key for a URL, such as /index.html?__WB_REVISION__=….
PrecacheFallbackPlugin new PrecacheFallbackPlugin({ fallbackURL }) A strategy plugin that returns a precached fallback when the strategy fails (handlerDidError).
PrecacheController, PrecacheRoute, PrecacheStrategy classes Lower-level building blocks for custom setups (multiple precaches, custom cache names).

createHandlerBoundToURL() throwing at startup deserves emphasis. It runs during the worker's top-level evaluation. If /index.html isn't in the manifest (a changed globPatterns, or a modifyURLPrefix that turned it into index.html without a slash in a different directory), script evaluation throws. The browser then treats the new worker as failed, and no update ever installs. Test the built worker in CI by loading it in a browser. Checking only that the build succeeded isn't enough.

The build side: injectManifest configuration

workbox-config.cjs
module.exports = {
  globDirectory: "dist/",
  globPatterns: ["**/*.{html,js,css,woff2,svg,webmanifest}"],
  globIgnores: ["**/node_modules/**/*", "sw.js", "**/*.map"],
  swSrc: "build/sw.js", // the bundled worker that contains the injection point
  swDest: "dist/sw.js",
  // Vite puts content-hashed files in assets/: no cache busting needed.
  dontCacheBustURLsMatching: /^assets\//,
  maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
  manifestTransforms: [
    // Receives [{ url, revision, size }], returns { manifest, warnings }.
    async (entries) => {
      const manifest = entries.filter((entry) => !entry.url.startsWith("reports/"));
      const total = manifest.reduce((sum, entry) => sum + entry.size, 0);
      const warnings = total > 1_500_000 ? [`Precache is ${total} bytes`] : [];
      return { manifest, warnings };
    },
  ],
};

Run it with npx workbox-cli injectManifest workbox-config.cjs, or call injectManifest() from workbox-build in a Node script. The Vite PWA plugin wraps the same machinery. The option reference, with defaults from workbox-build:

Option Default Notes
globDirectory (required for glob-based builds) Root of the build output.
globPatterns ["**/*.{js,wasm,css,html}"] Files to include. Add fonts, SVGs and the web app manifest explicitly if you want them.
globIgnores ["**/node_modules/**/*"] Setting this replaces the default; re-add node_modules if needed.
maximumFileSizeToCacheInBytes 2097152 (2 MiB) Larger files are dropped with a warning.
dontCacheBustURLsMatching none Matching URLs get revision: null.
modifyURLPrefix none Object mapping URL prefixes to replacements, such as { "dist/": "/" }.
manifestTransforms none Array of functions (entries, compilation?) => { manifest, warnings? }.
additionalManifestEntries none Extra string or { url, revision, integrity? } entries.
templatedURLs none Maps a server-rendered URL to globs (their contents form the revision) or a fixed revision string.
injectionPoint "self.__WB_MANIFEST" Must occur exactly once in swSrc, comments included.

Workbox applies the transforms in a fixed order: the size filter, then modifyURLPrefix, then dontCacheBustURLsMatching, then your manifestTransforms, then additionalManifestEntries. Your regular expressions therefore have to match the URLs after modifyURLPrefix has rewritten them. If you prefix everything with /, dontCacheBustURLsMatching must be /^\/assets\//, not /^assets\//.

generateSW vs injectManifest

generateSW writes the whole worker for you from configuration (runtimeCaching, navigateFallback, skipWaiting, and so on). injectManifest only injects the manifest into a worker you wrote. Use generateSW for simple sites. Switch to injectManifest when you need push handlers, custom routing logic, or anything else configuration can't express. Workbox Fundamentals and Advanced Workbox cover both modes.

URL matching options: how a request finds its precache entry

PrecacheRoute doesn't require the request URL to match a manifest URL exactly. For every request, it generates candidate URLs in this order and uses the first one that is in the precache:

  1. The request URL with the fragment (#…) removed.
  2. That URL with every query parameter whose name matches ignoreURLParametersMatching removed. The default is [/^utm_/, /^fbclid$/].
  3. If the path ends in / and directoryIndex is set (default "index.html"), the URL with directoryIndex appended.
  4. If cleanURLs is true (the default), the URL with .html appended to the path.
  5. Every URL returned by your urlManipulation({ url }) callback. It must return URL objects, not strings.

Two consequences are easy to miss. First, query parameters that aren't ignored cause misses. /?source=pwa (a common start_url) doesn't match /index.html unless source is in ignoreURLParametersMatching. The request then goes to the network, or to the navigation route, which may be what you want. Second, the default list is short. Click-ID and campaign parameters from other analytics tools aren't in it. Extend the list to cover every tracking parameter your links carry, but never ignore parameters your server uses to select content.

Runtime caching alongside the precache

Runtime routes are registered after the precache route. The precache route only matches precached URLs, so everything else falls through to them. A representative Workbox configuration:

src/sw.js (runtime routes, after precacheAndRoute)
import { registerRoute } from "workbox-routing";
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from "workbox-strategies";
import { ExpirationPlugin } from "workbox-expiration";
import { CacheableResponsePlugin } from "workbox-cacheable-response";

// Hashed assets that weren't precached (lazy chunks): immutable, so cache-first.
registerRoute(
  ({ url, request }) =>
    url.origin === self.location.origin &&
    url.pathname.startsWith("/assets/") &&
    ["script", "style", "font"].includes(request.destination),
  new CacheFirst({
    cacheName: "assets-v1",
    plugins: [new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 60 })],
  })
);

// Images: cache-first with an LRU cap; purge this cache first if quota runs out.
registerRoute(
  ({ request }) => request.destination === "image",
  new CacheFirst({
    cacheName: "images-v1",
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }), // 0 = opaque cross-origin images
      new ExpirationPlugin({ maxEntries: 120, maxAgeSeconds: 60 * 60 * 24 * 30, purgeOnQuotaError: true }),
    ],
  })
);

// API reads: fresh when possible, cached when the network is slow or down.
registerRoute(
  ({ url, request }) => url.pathname.startsWith("/api/") && request.method === "GET",
  new NetworkFirst({
    cacheName: "api-v1",
    networkTimeoutSeconds: 4,
    plugins: [new CacheableResponsePlugin({ statuses: [200] })],
  })
);

// Avatars from a third-party CDN: show the cached copy, refresh in the background.
registerRoute(
  ({ url }) => url.origin === "https://avatars.example-cdn.com",
  new StaleWhileRevalidate({ cacheName: "avatars-v1" })
);

Three rules keep runtime caches from undermining the precache:

  • Runtime caches must not store precached URLs. If a runtime route could also match a precached URL, register it after the precache route (the first match wins), so the precache always answers.
  • Every runtime cache needs a bound. Workbox's ExpirationPlugin enforces maxEntries (LRU by last access) and maxAgeSeconds. With purgeOnQuotaError: true, the plugin deletes the cache when a write throws QuotaExceededError. Opaque responses are especially expensive. Chromium pads their size for quota accounting to avoid leaking cross-origin sizes, so a few hundred opaque images can use a surprising amount of quota. See Storage Quotas & Persistence.
  • Version runtime cache names when the stored format changes. Bump api-v1 to api-v2 when the API's response shape changes, and delete api-v1 in activate.

Choosing strategies per route is covered in Caching Strategies. Timeouts and lie-fi handling are covered in Offline UX & Fallbacks.

On-demand "save for offline" caching

Some content is too large or too personal to precache, but a user may explicitly want it available offline: an article to read on a flight, a map region, a document, a course module. "Save for offline" is user-initiated runtime caching. The user decides membership, and you decide the storage layout. It differs from precaching in three important ways:

  1. It isn't tied to a service worker version. Saved content must survive deploys. Store it in a dedicated cache (app-saved-v1) that your precache cleanup never touches.
  2. It must be removable. Users need to see what is saved, how much space it uses, and a way to delete it.
  3. It can be done without the service worker. The Cache API is available in window contexts. The page can download and store content directly, and the worker only has to read from the saved cache when the network fails.

The module below saves a page plus its assets atomically, keeps an index of saved items in the same cache, uses the Web Locks API so two tabs can't corrupt the index, and handles shared assets correctly on removal:

save-for-offline.js
const SAVED_CACHE = "app-saved-v1";
const INDEX_KEY = "/__saved__/index.json"; // synthetic URL, never requested from the network

async function readIndex(cache) {
  const response = await cache.match(INDEX_KEY);
  return response ? response.json() : {};
}

function writeIndex(cache, index) {
  return cache.put(
    INDEX_KEY,
    new Response(JSON.stringify(index), { headers: { "Content-Type": "application/json" } })
  );
}

// Serialize read-modify-write of the index across tabs (Web Locks: Chrome 69, Firefox 96, Safari 15.4).
function withIndexLock(fn) {
  return navigator.locks ? navigator.locks.request("saved-index", fn) : fn();
}

/**
 * Call directly from a click handler: Firefox may show a permission prompt for persist().
 * @param {{url: string, title: string, assets?: string[]}} item
 */
export async function saveForOffline({ url, title, assets = [] }) {
  const pageURL = new URL(url, location.href).href;
  const assetURLs = [...new Set(assets.map((a) => new URL(a, location.href).href))].filter(
    (a) => a !== pageURL
  );

  // Ask for persistent storage first, while the click's user activation is fresh.
  const persistRequest = navigator.storage?.persisted
    ? navigator.storage.persisted().then((granted) => granted || navigator.storage.persist())
    : Promise.resolve(false);

  const cache = await caches.open(SAVED_CACHE);
  try {
    // addAll() is atomic: all URLs are stored, or none are (quota errors included).
    await cache.addAll(
      [pageURL, ...assetURLs].map((u) => new Request(u, { cache: "no-cache", credentials: "same-origin" }))
    );
  } catch (error) {
    if (error?.name === "QuotaExceededError") {
      throw new Error("There isn't enough free storage to save this page.", { cause: error });
    }
    throw new Error(`Couldn't download everything needed to save "${title}".`, { cause: error });
  }

  await withIndexLock(async () => {
    const index = await readIndex(cache);
    index[pageURL] = { title, assets: assetURLs, savedAt: Date.now() };
    await writeIndex(cache, index);
  });

  return { persisted: await persistRequest.catch(() => false) };
}

export async function removeFromOffline(url) {
  const pageURL = new URL(url, location.href).href;
  const cache = await caches.open(SAVED_CACHE);
  await withIndexLock(async () => {
    const index = await readIndex(cache);
    const item = index[pageURL];
    if (!item) return;
    delete index[pageURL];
    // Keep assets another saved page still references (shared images, CSS).
    const stillUsed = new Set(Object.values(index).flatMap((entry) => entry.assets));
    await cache.delete(pageURL);
    await Promise.all(item.assets.filter((a) => !stillUsed.has(a)).map((a) => cache.delete(a)));
    await writeIndex(cache, index);
  });
}

export async function listSaved() {
  const cache = await caches.open(SAVED_CACHE);
  const index = await readIndex(cache);
  return Object.entries(index)
    .map(([url, item]) => ({ url, ...item }))
    .sort((a, b) => b.savedAt - a.savedAt);
}

The worker then consults the saved cache when the network fails. For a multi-page app, a navigation handler looks like this:

sw.js (reading saved content)
const SAVED_CACHE = "app-saved-v1";

// With the incremental worker above, the offline page is stored under its revisioned key.
async function matchPrecachedOfflinePage() {
  const cacheKey = entriesByURL.get(new URL("/offline.html", self.location.href).href)?.cacheKey;
  if (!cacheKey) return undefined;
  const cache = await caches.open(PRECACHE_NAME);
  return cache.match(cacheKey);
}

async function handleNavigation(event) {
  try {
    return await fetch(event.request);
  } catch {
    // Offline: saved copy first, then any runtime-cached copy, then the offline page.
    return (
      (await caches.match(event.request, { cacheName: SAVED_CACHE })) ??
      (await caches.match(event.request)) ??
      (await matchPrecachedOfflinePage()) ??
      Response.error()
    );
  }
}

A few details matter in production:

  • Fetches from the page go through the service worker. cache: "no-cache" controls the HTTP cache, not the worker. If a runtime route answers the article request from a stale runtime cache, you save the stale copy. Let the worker's strategy for these URLs be network-first, or exempt requests carrying a marker header.
  • Know what addAll() can't store. Cross-origin images without CORS headers fail (addAll() rejects opaque responses). Proxy them through your origin, serve them with CORS, or store them with cache.put() after a no-cors fetch, accepting the opaque padding cost.
  • Persistence isn't guaranteed. navigator.storage.persist() is supported in Chrome 55, Firefox 57 and Safari 15.2 and later. Chromium grants it heuristically, based on signals such as the site being installed or having high engagement, without a prompt. Firefox asks the user. WebKit grants it based on heuristics such as whether the site runs as a Home Screen web app. Tell users when content may be evicted.
  • Large downloads belong in Background Fetch on Chromium. A 200 MB course module downloaded with fetch() dies when the user closes the tab. Background Fetch (Chromium-only) hands the download to the browser, with a system notification that shows progress.
  • Structured data belongs in IndexedDB. Saving an API response as a Response works for read-only display. If users edit the saved data offline, store it in IndexedDB and follow the patterns in Offline-First Data & Sync.

The user-facing side (save buttons, progress, storage usage, "saved" badges) is covered in Offline UX & Fallbacks.

SPA navigation fallback to index.html

A single-page app serves the same HTML shell for every route and renders the route client-side. Online, the server implements this with a rewrite (every unknown path returns index.html). Offline, the service worker has to do it: for any navigation request inside the app, respond with the precached index.html, whatever the URL.

flowchart TD
    A["fetch event"] --> B{"request.mode is navigate?"}
    B -- no --> Z["other routes or network"]
    B -- yes --> C{"URL precached exactly?"}
    C -- yes --> D["serve that precached file"]
    C -- no --> E{"matches denylist?"}
    E -- yes --> Z
    E -- no --> F{"matches allowlist?"}
    F -- no --> Z
    F -- yes --> G["serve precached index.html"]
    G --> H["client router renders the URL"]

The URL in the address bar stays what the user requested (/projects/42/settings). Only the response body comes from index.html, and the client-side router reads location.pathname to render the right view. This is why the shell must use root-absolute URLs for its scripts and styles (/assets/index-DiwrgTda.js). A relative assets/index.js would resolve to /projects/42/assets/index.js and fail.

Workbox NavigationRoute: allowlist and denylist semantics

NavigationRoute(handler, { allowlist, denylist }) from workbox-routing has precise matching rules:

  • It matches only requests whose mode is "navigate". That includes top-level navigations and iframe navigations, but never fetch() calls or subresources.
  • The regular expressions are tested against url.pathname + url.search, for example /projects/42?tab=settings. They are not tested against the full URL, so ^https:// patterns never match.
  • denylist is checked first. Any match means the route doesn't handle the request, even if an allowlist pattern also matches.
  • allowlist defaults to [/./] (everything). denylist defaults to [].
  • These regular expressions run on every navigation. Workbox's documentation warns against complex expressions, citing a real performance issue (#3077). Avoid nested quantifiers that can backtrack catastrophically.

With generateSW, the equivalent options are navigateFallback: "/index.html", navigateFallbackAllowlist and navigateFallbackDenylist, with the same precedence (the denylist wins).

Pitfalls of the navigation fallback

The fallback is simple to add and easy to get subtly wrong. Each of the following has broken production apps:

  1. API URLs opened in a tab. A user (or a developer) opens /api/users/42 directly, or a link in an email points to a JSON download. Without a denylist entry, the worker answers with your SPA shell. The router shows a "not found" view, and the user never sees the JSON. Denylist ^/api/.
  2. Authentication callbacks. OAuth and OIDC redirects land on URLs like /auth/callback?code=…&state=…, which the server must process to set a session cookie. If the shell answers, the code is never exchanged, and the login loops. Denylist every callback path, including third-party SDK handlers (for example /__/auth/handler for Firebase Authentication).
  3. Server-rendered areas inside the scope. Admin panels, a blog served by another system, legacy pages, /logout. Each needs a denylist entry or its own scope.
  4. Static files that aren't precached. /sitemap.xml, /robots.txt, /feed.xml, /downloads/report.pdf opened as navigations. A "file-like path" rule (last segment contains a dot) catches most of them. Check that none of your real routes contain dots (/v1.2/changelog, /users/jane.doe) before you adopt it.
  5. Soft 404s. Every unknown URL now "succeeds" with a 200 shell and a client-side 404 view. Users are fine with that. Your monitoring may not be, because the service worker hides the server's real 404. (Search engine crawlers don't run your service worker, so SEO isn't affected; see SEO for PWAs.)
  6. Server redirects are bypassed. If the server redirects /old-path to /new-path, the worker's shell answer skips that redirect for every user who has the worker installed. Implement critical redirects in the client router too, or denylist the legacy paths.
  7. The shell is frozen until the worker updates. A precached index.html is served from cache even when the network is available. Changes to the HTML (meta tags, inline configuration, analytics snippets) reach users only after the new worker activates. This is by design, but plan for it, and read Updating Service Workers.
  8. Lazy chunks vanish after a deploy. The old shell (served by the old worker) references route-settings-C8f2kQpZ.js. If that chunk wasn't precached, and the deploy deleted it from the server, lazy navigation fails with a chunk load error. Precache all route chunks, keep old hashed assets on the server for a grace period, and handle chunk load failures in the router by reloading once.
  9. Response headers are frozen too. The precached shell keeps the headers it was stored with. A per-response CSP nonce is reused for every navigation until the next update, which defeats the purpose of a nonce. Set-Cookie and other per-request headers never happen. Prefer hash-based CSP for precached HTML, as covered in Content Security Policy.
  10. Navigation preload is wasted. If navigations are answered from the precache, a preload request for every navigation downloads HTML that is never used. Don't enable navigation preload for a cache-first shell. It's designed for network-first navigations.
  11. Redirected shell responses. If you precache / and the server redirects it to /index.html, the stored response is redirected, and serving it to a navigation is a network error (see redirected responses). Precache the final URL.
  12. Scope and start_url mismatch. The fallback only applies inside the worker's scope. A start_url of /?source=pwa works because / plus the directory index resolves to the precached index.html, or the navigation route catches it. A start_url outside the scope bypasses the worker entirely. See Registration & Scope.

Multi-page apps should not use a shell fallback

If your site is server-rendered, with a separate HTML document per URL, answering every navigation with one cached document is wrong. Use network-first for navigations with an offline fallback page instead. SPA vs MPA PWAs explains the architectural difference. The Static Routing API can also send known server-only paths straight to the network without starting the worker at all.

Common pitfalls

Symptom Cause Fix
Install fails with TypeError: … Request failed One URL in the manifest returns non-2xx, or is cross-origin without CORS Fetch each URL individually (the incremental worker logs which one); fix the path or remove the entry
Install fails with InvalidStateError The same URL appears twice in one addAll() batch Deduplicate at build time
Users stuck on an old version after a deploy Manifest loaded at runtime (not inlined), or sw.js served with a long max-age and updateViaCache: "all" Inline the manifest; serve sw.js with Cache-Control: no-cache
Every deploy re-downloads the whole precache Revisions derived from build IDs or mtimes, or a versioned cache per deploy Use content hashes and revision-keyed entries
New JS with old HTML after an update Unhashed HTML precached without cache: "reload" Use reload (or no-cache) for revisioned entries
Precache deleted while old tabs still open Cleanup ran in install Clean up only in activate
Another app on the same origin lost its offline support Cleanup deleted every cache "not mine" Match names by prefix and scope
Offline navigation shows the browser's error page The shell was stored as a redirected response, or it isn't in the precache Precache the final URL; copy redirected responses
Workbox build error about multiple injection points self.__WB_MANIFEST also appears in a comment or string Keep exactly one occurrence
non-precached-url error at worker startup createHandlerBoundToURL() URL isn't in the manifest Fix globPatterns / URL prefix; test the built worker
First visit uses several megabytes of data Oversized precache (images, all chunks, polyfills) Budget in CI; move non-critical assets to runtime caching or a warm tier

Debugging precaching

Inspect the caches. In Chrome and Edge DevTools, open Application › Storage › Cache storage. Each cache is listed by name (with the scope suffix, if you followed the naming advice). Selecting a cache shows every key, including __rev/__WB_REVISION__ parameters, along with response headers and a preview. Firefox shows the same data under Storage › Cache Storage, and Safari's Web Inspector under Storage. Browser DevTools covers each tool in depth.

Watch the install. In the Network panel, requests made by the service worker show a gear icon in Chromium. Filter by the worker's requests to see exactly which manifest entries were downloaded during install, and which ones came from (disk cache).

Measure the precache from the console. Run the snippet below in the DevTools console of a controlled page to list every cache with its entry count and approximate size:

DevTools console
for (const name of await caches.keys()) {
  const cache = await caches.open(name);
  const requests = await cache.keys();
  let bytes = 0;
  for (const request of requests) {
    const response = await cache.match(request);
    bytes += (await response.blob()).size; // opaque responses report 0 here
  }
  console.log(`${name}: ${requests.length} entries, ${(bytes / 1024).toFixed(1)} KB`);
}

Force a clean slate. Application › Storage › Clear site data removes caches, IndexedDB and the service worker registration together. Unregistering the worker alone leaves its caches behind. Enable Update on reload in Application › Service workers while you iterate on sw.js, so every reload installs the latest worker.

Use Workbox's development logs. Development builds of Workbox log every precache decision: which URLs were downloaded, which were already up to date, which requests were answered from the precache, and why a navigation route didn't match (denylist or allowlist). Production builds strip these logs.

Browser support

Support data as of September 2026. Check MDN's Cache API compatibility data and caniuse.com for live data.

Feature Chrome Edge Firefox Safari Safari on iOS
Cache.addAll() ✅ 46 ✅ 16 ✅ 41 ✅ 11.1 ✅ 11.3
Request.cache ("reload", "no-cache") ✅ 64 ✅ 14 ✅ 48 ✅ 10.1 ✅ 10.3
Request.integrity (SRI in fetch) ✅ 46 ✅ 14 ✅ 51 ✅ 10.1 ✅ 10.3
Response.redirected ✅ 57 ✅ 16 ✅ 49 ✅ 10.1 ✅ 10.3
StorageManager.persist() ✅ 55 ✅ 79 ✅ 57 ✅ 15.2 ✅ 15.2
StorageManager.estimate() ✅ 61 ✅ 79 ✅ 57 ✅ 17 ✅ 17
Web Locks API (navigator.locks) ✅ 69 ✅ 79 ✅ 96 ✅ 15.4 ✅ 15.4
ES module service workers ✅ 91 ✅ 91 ✅ 147 ✅ 15 ✅ 15
navigator.connection.saveData ✅ 65 ✅ 79 ❌ ❌ ❌
Background Fetch ✅ 74 ✅ 79 ❌ ❌ ❌

Precaching itself needs nothing beyond service workers and the Cache API, which every current engine supports. The Chromium-only rows only affect the optional refinements (data-saver-aware warm caching, large background downloads).

Further reading

On this site

External references