Skip to content

Workbox fundamentals

Workbox is Google's open-source collection of JavaScript libraries and build tools for writing service workers. It gives you a request router, five ready-made caching strategies, a plugin system, revision-aware precaching, and a window-side helper for registration and updates. It also ships three build integrations (a CLI, a Node API and a webpack plugin) that generate the precache manifest from your build output. It matters because most of the service worker code in production today is either Workbox itself or a descendant of it, such as vite-plugin-pwa (which wraps workbox-build), Serwist (a fork), and many framework PWA plugins. When you understand what Workbox does under the hood, you can debug all of them.

Key takeaways

  • The current release is Workbox 7.4.1 (May 2026). Chrome's Aurora team owns the project. Every 7.x release since 7.0.0 (May 2023) has been dependency, security and tooling maintenance. The runtime API has not changed, so treat Workbox as stable, mature and effectively feature-frozen.
  • Choose generateSW when all you need is precaching plus declarative runtime caching. Choose injectManifest as soon as you need your own code in the worker, such as push, custom routes, custom plugins or message handlers. injectManifest only replaces self.__WB_MANIFEST. Bundling the worker is still your job.
  • Routing is first-match-wins, only http(s) URLs are routed, and an unmatched request is not answered by the service worker at all. RegExp routes match cross-origin URLs only when the match starts at index 0 of the full URL.
  • The built-in strategies differ in what they cache by default. CacheFirst stores only 200 responses (and CacheOnly never writes at all). NetworkFirst and StaleWhileRevalidate also store opaque (status: 0) responses unless you add your own cacheWillUpdate plugin.
  • StrategyHandler.fetch() automatically consumes event.preloadResponse for navigations and ignores fetchOptions for them. That behavior matters when you enable navigation preload.
  • workbox-window flags any update found more than 60 seconds after register(), or after an earlier updatefound, as isExternal: true. Write your "new version available" UI so it handles that case.

What Workbox is, and what it is not

Workbox has two halves that run in completely different environments:

  1. Runtime libraries that run inside the service worker (workbox-routing, workbox-strategies, workbox-precaching, plugins) or in the page (workbox-window). They are thin, well-tested wrappers around the Cache Storage API, the fetch event, IndexedDB and the Clients API.
  2. Build tools that run in Node.js at build time (workbox-build, workbox-cli, workbox-webpack-plugin). They scan your output directory or your webpack compilation, hash every file, and either write a complete service worker (generateSW) or inject a manifest into one you wrote (injectManifest).

Workbox is not a framework and it does not change how service workers behave. Everything it does, you could write by hand. The caching strategies and precaching pages on this site show the vanilla implementations. You get battle-tested edge-case handling in return, for example:

  • Precache entries are keyed by revision (?__WB_REVISION__=<hash>), so an update downloads only the files that changed.
  • Revisioned entries are fetched with cache: "reload", which bypasses the HTTP cache. A stale copy never gets precached.
  • A redirected response is copied into a fresh Response, because browsers refuse to use a redirected response for a navigation.
  • Every cache write goes through event.waitUntil(), so the browser doesn't stop the worker in the middle of a write.
  • A QuotaExceededError can trigger callbacks that purge whole caches.
  • Background-sync replay falls back to replaying on worker start-up in browsers without the Background Sync API.

Workbox does not do anything about the web app manifest, install prompts or push subscriptions. It does not make a site installable on its own either. For those, see installability and push notifications.

Current version and maintenance status

As of September 2026, the latest release of every Workbox package on npm is 7.4.1, published on 4 May 2026. The table lists the 7.x release line, taken from the npm registry and the GitHub release notes.

Version Published Minimum Node.js (build tools) Release notes summary
6.6.0 26 May 2023 10 Last 6.x feature release (TypeScript updates)
7.0.0 31 May 2023 16 Breaking change: "Minimum required version Node 16"
7.1.0 23 Apr 2024 16 "Updating dependencies with critical vulnerabilities"
7.1.1 28 May 2024 16 Patch published to npm without GitHub release notes
7.3.0 29 Oct 2024 16 "Critical dependency updates" (7.2.0 was never published)
7.4.0 19 Nov 2025 20 "Critical dependency updates"; glob upgraded from v7 to v11; fix for unhandled rejections in StrategyHandler.doneWaiting()
7.4.1 4 May 2026 20 Rollup 4 migration for workbox-build, lodash replaced by eta for templating, Dependabot updates

Several facts about the project's status follow from its own README and release history:

  • Ownership. The repository README has a "Maintenance update" section. It states that Workbox was "originally developed by members of Chrome's developer relations team" and that "Chrome's Aurora team will be the new owners of Workbox". The repository is not archived, and dependency pull requests were still being merged in September 2026.
  • No new runtime features in v7. The 7.0.0 release notes list one breaking change, the Node 16 requirement. Every later release is described as dependency, security or tooling maintenance. The service worker API you write against (registerRoute, Strategy, precacheAndRoute, plugins) is essentially the one Workbox 6 introduced in late 2020 (6.0.0 was published on 30 November 2020).
  • The Node.js floor moved. workbox-build, workbox-cli and workbox-webpack-plugin 7.4.x declare "engines": {"node": ">=20.0.0"}. A CI image running Node 18 has to stay on 7.3.0 or be upgraded.
  • A community fork exists. Serwist describes itself as "a fork of Workbox that came to be due to its development being stagnated". It has reorganized the API around a single Serwist class and ships only ESM. Advanced Workbox covers migrating to it in detail.

What this means in practice: Workbox is a safe dependency. Its API is stable, documented and covered by tests, and its security dependencies are maintained. Don't expect new features, such as first-class support for the Static Routing API. If you need those, write them yourself on top of Workbox (the runtime is modular enough for that) or evaluate Serwist, which has, for example, experimental requestRules support for InstallEvent.addRoutes(). (MDN's compatibility data lists addRoutes() in Chromium 123+ and Safari 27, not in Firefox, as of September 2026.)

The Workbox module map

Every Workbox package is published separately on npm, and every one is versioned in lockstep. Mixing versions is not supported. Each module evaluates a version marker of the form self['workbox:<module>:<version>'] when it loads. Install every workbox-* package at the same version, ideally through a single ^7.4.1 range or an npm overrides entry, so the bundle never contains two copies of workbox-core.

Package Runs in Main exports Purpose
workbox-core SW cacheNames, setCacheNameDetails, clientsClaim, copyResponse, registerQuotaErrorCallback, skipWaiting (deprecated) Shared internals, cache naming, logging, assertions
workbox-routing SW registerRoute, Route, RegExpRoute, NavigationRoute, Router, setDefaultHandler, setCatchHandler Matching requests to handlers
workbox-strategies SW CacheFirst, CacheOnly, NetworkFirst, NetworkOnly, StaleWhileRevalidate, Strategy, StrategyHandler Response strategies and the base class for custom ones
workbox-precaching SW precacheAndRoute, precache, addRoute, cleanupOutdatedCaches, matchPrecache, createHandlerBoundToURL, getCacheKeyForURL, PrecacheController, PrecacheRoute, PrecacheStrategy, PrecacheFallbackPlugin, addPlugins Install-time caching of a revisioned manifest
workbox-expiration SW ExpirationPlugin, CacheExpiration Entry-count and age limits (metadata in IndexedDB)
workbox-cacheable-response SW CacheableResponsePlugin, CacheableResponse Status/header rules for what may be cached
workbox-background-sync SW BackgroundSyncPlugin, Queue, QueueStore, StorableRequest Replaying failed requests
workbox-broadcast-update SW BroadcastUpdatePlugin, BroadcastCacheUpdate, responsesAreSame Telling pages that a cached response changed
workbox-range-requests SW RangeRequestsPlugin, createPartialResponse Serving 206 responses from full cached bodies
workbox-navigation-preload SW enable, disable, isSupported Navigation preload wrapper
workbox-streams SW strategy, concatenate, concatenateToResponse, isSupported Composing streaming responses
workbox-recipes SW pageCache, imageCache, staticResourceCache, googleFontsCache, offlineFallback, warmStrategyCache One-line common patterns
workbox-google-analytics SW initialize Queues Google Analytics hits while offline
workbox-sw SW global workbox object CDN loader for importScripts()-based workers
workbox-window Page Workbox, messageSW, WorkboxEvent Registration, lifecycle events, messaging
workbox-build Node generateSW, injectManifest, getManifest, copyWorkboxLibraries Build-time manifest generation
workbox-cli Node workbox binary CLI wrapper around workbox-build
workbox-webpack-plugin Node (webpack) GenerateSW, InjectManifest webpack integration

At runtime the pieces fit together like this:

flowchart LR
    FE["fetch event"] --> R["Router (workbox-routing)"]
    R -->|"first matching Route"| S["Strategy (workbox-strategies)"]
    R -->|"no match, no default handler"| NET["Browser handles request normally"]
    S --> H["StrategyHandler (one per request)"]
    H -->|"plugin callbacks"| P["Plugins: expiration, cacheable-response, broadcast-update, ..."]
    H --> CS["Cache Storage"]
    H --> N["fetch()"]
    S -->|"throws"| CH["Catch handler / handlerDidError"]

The Router is created lazily by the first call to registerRoute(), setDefaultHandler() or setCatchHandler(). When it is created, it adds two listeners on self: a fetch listener, and a message listener for the CACHE_URLS message described below. precacheAndRoute() registers its route on that same default router. A Strategy is stateless across requests. Each call to strategy.handle() creates a new StrategyHandler, which carries the per-request state, including each plugin's private state object.

Three ways to load Workbox into a service worker

Install the modules you use and import them as ES modules in your worker source. A bundler (Rollup, esbuild, webpack or Vite) resolves them into a single file:

Terminal
npm install --save-dev workbox-routing workbox-strategies workbox-precaching \
  workbox-expiration workbox-cacheable-response workbox-window workbox-build

Each module's source guards development-only code with process.env.NODE_ENV !== 'production'. Your bundler must replace process.env.NODE_ENV with a string literal. If it doesn't, you get either a ReferenceError: process is not defined at runtime or a production bundle that ships every assertion and log message. Rollup needs @rollup/plugin-replace, esbuild needs --define:process.env.NODE_ENV='"production"', and webpack and Vite do the replacement automatically based on their mode.

The workbox-sw CDN loader

workbox-sw is a small loader for classic (non-module) service workers. It exposes a global workbox object whose properties load the matching module from Google's CDN through importScripts() the first time you touch them:

sw.js
importScripts(
  "https://storage.googleapis.com/workbox-cdn/releases/7.4.1/workbox-sw.js",
);

// Must run before any workbox.* module is touched.
workbox.setConfig({ debug: false });

// Touch every module you need at the top level, during initial evaluation.
const { registerRoute } = workbox.routing;
const { StaleWhileRevalidate } = workbox.strategies;
const { ExpirationPlugin } = workbox.expiration;

registerRoute(
  ({ request }) => request.destination === "style",
  new StaleWhileRevalidate({
    cacheName: "css",
    plugins: [new ExpirationPlugin({ maxEntries: 40 })],
  }),
);

The loader works through a Proxy: the first access to workbox.strategies calls importScripts() for workbox-strategies.prod.js (or .dev.js when debug is on). debug defaults to true only when the worker's hostname is localhost. Three constraints follow from the service worker specification:

  • importScripts() of a new URL fails after installation. Once the worker is installed, the browser only serves importScripts() from the scripts it stored during installation. If the first access to a module happens inside a fetch or message handler, it throws a NetworkError. Always reference every module at the top level.
  • Module workers can't use it. importScripts() throws a TypeError in a worker registered with {type: "module"}.
  • The CDN is a third-party origin in your critical path. If you use the loader, self-host the files. npx workbox-cli copyLibraries dist/ copies the runtime into dist/workbox-v7.4.1/, and workbox.setConfig({ modulePathPrefix: "/workbox-v7.4.1/" }) points the loader at them. A modulePathCb(moduleName, debug) callback is also available if you need full control over the paths.

The loader only makes sense for sites with no JavaScript build step. Everywhere else, bundle.

Letting generateSW write the whole worker

With generateSW, the worker is written for you. You never import Workbox yourself. You describe what to cache in configuration, and workbox-build renders a template, bundles it with Rollup and writes it out. The next section covers what that output contains.

generateSW vs injectManifest

Both modes produce a precache manifest: a list of {url, revision} objects built by globbing your output directory (or, with webpack, from the compilation's assets). The difference is who writes the service worker.

generateSW injectManifest
Who writes sw.js Workbox renders a template You do
Configuration surface Declarative: runtimeCaching, navigateFallback, skipWaiting, clientsClaim, navigationPreload, cleanupOutdatedCaches, offlineGoogleAnalytics, importScripts Only manifest options (globPatterns, swSrc, swDest, injectionPoint, transforms); everything else is code
Push, notification click, custom message handlers Only via importScripts of an extra file Written directly
Custom plugins / strategies in runtime caching Possible (handler can be a function, options.plugins accepts instances), but serialized through the template Native
Bundling of the worker Done by workbox-build (Rollup + Babel + Terser) Your job with workbox-build/CLI; done by webpack when you use the InjectManifest plugin (compileSrc: true)
Output format Classic script; the Workbox runtime in a separate workbox-<hash>.js chunk unless inlineWorkboxRuntime: true Whatever your bundler emits
Typical users Static sites, simple SPAs, documentation sites Apps with push, background sync, complex routing
flowchart TD
    A["Do you need any code in the service worker besides caching rules?"] -->|"No"| B["generateSW"]
    A -->|"Yes: push, sync, custom routes, messaging"| C["injectManifest"]
    B --> D{"Need one small extra listener?"}
    D -->|"Yes"| E["generateSW + importScripts: ['extra-sw.js']"]
    D -->|"No"| F["Done"]
    C --> G{"Using webpack?"}
    G -->|"Yes"| H["InjectManifest plugin compiles swSrc for you"]
    G -->|"No"| I["Bundle sw.js yourself (esbuild/Rollup), then run injectManifest on the bundle"]

Teams usually start with generateSW and switch to injectManifest later. The switch is cheap, because the template that generateSW renders is short and easy to reproduce by hand (see the generated worker, annotated).

What generateSW actually generates

The template in workbox-build renders the following steps, in this order:

  1. importScripts(...) for every entry in the importScripts option.
  2. enable() from workbox-navigation-preload, if navigationPreload: true.
  3. setCacheNameDetails({prefix: cacheId}), if cacheId is set.
  4. self.skipWaiting() unconditionally if skipWaiting: true. Otherwise it adds a message listener that calls self.skipWaiting() when it receives {type: "SKIP_WAITING"}. That listener is the contract workbox-window's messageSkipWaiting() relies on.
  5. clientsClaim(), if clientsClaim: true.
  6. precacheAndRoute(<manifest>, {directoryIndex, ignoreURLParametersMatching}), if the manifest isn't empty.
  7. cleanupOutdatedCaches(), if cleanupOutdatedCaches: true.
  8. registerRoute(new NavigationRoute(createHandlerBoundToURL(navigateFallback), {allowlist, denylist})), if navigateFallback is set.
  9. One registerRoute() per runtimeCaching entry, in array order.
  10. initialize() from workbox-google-analytics, if offlineGoogleAnalytics is set.
  11. self.__WB_DISABLE_DEV_LOGS = true, if disableDevLogs: true.

Steps 7 and 8 are nested inside the template's "manifest isn't empty" branch. A runtime-caching-only configuration (for example globPatterns: []) therefore silently gets no cleanupOutdatedCaches() call and no navigation fallback, even when you set those options. That is consistent (there is no precache to fall back to), but it surprises people who strip the precache to debug something.

That template is then bundled with Rollup, @babel/preset-env (targets from babelPresetEnvTargets, default ["chrome >= 56"]), @rollup/plugin-replace (for process.env.NODE_ENV) and, in production mode, Terser with top-level and _-prefixed property mangling. With the default inlineWorkboxRuntime: false, Rollup emits AMD format. A small define() loader goes into sw.js, and all Workbox code goes into a separate workbox-<hex hash>.js chunk next to it. The worker loads that chunk with importScripts(). The split lets a deploy that changes only the manifest re-download a few kilobytes instead of the whole runtime. It also means:

  • You must deploy workbox-*.js alongside sw.js, from the same directory.
  • sw.js is a classic script. Don't register it with {type: "module"}.
  • Setting inlineWorkboxRuntime: true produces a single self-contained file (Rollup es format, but with nothing left to import). Use it when your host or CDN makes the extra file awkward.

mode defaults to process.env.NODE_ENV and falls back to "production". sourcemap defaults to true, so sw.js.map and workbox-*.js.map are emitted as well. Exclude them from your deploy, or serve them only to authorized users, if you don't publish source maps.

What injectManifest actually does

injectManifest reads swSrc and searches for the injectionPoint string, self.__WB_MANIFEST by default. It replaces that string with the JSON manifest, fixes up the source map if one exists, and writes swDest. That's all. Three consequences:

  • It does not bundle. If swSrc contains import {precacheAndRoute} from "workbox-precaching", the output still contains that bare import, and the browser rejects it. With workbox-build or workbox-cli, you bundle first and inject second (the complete example below shows the order). The webpack InjectManifest plugin is the exception: it compiles swSrc in a child compilation, then injects.
  • The injection point must appear exactly once. Zero matches fail with "Unable to find a place to inject the manifest". Two or more fail with "Please ensure that your 'swSrc' file contains only one match". The search is a plain global regex over the source, so a comment that mentions self.__WB_MANIFEST counts as a match. Minifiers can also rewrite self to a local alias. Inject into unminified output, or minify after injecting.
  • swSrc and swDest must differ. If they point to the same file, the second build finds no injection point, because the first build already replaced it. Workbox reports the error same-src-and-dest in that case.

A TypeScript declaration keeps self.__WB_MANIFEST typed:

src/sw-globals.d.ts
import type { PrecacheEntry } from "workbox-precaching";

declare global {
  interface ServiceWorkerGlobalScope {
    __WB_MANIFEST: Array<PrecacheEntry | string>;
  }
}
export {};

Build-time options shared by all modes

These options control how the manifest is built. They apply to generateSW, injectManifest and getManifest in workbox-build, the CLI and the webpack plugins (except the glob options, which webpack doesn't use).

Option Default What it does
globDirectory — (required except generateSW with only runtime caching) Directory the glob patterns are evaluated against, usually your deploy root
globPatterns ["**/*.{js,wasm,css,html}"] Files to precache. Images, fonts and JSON are not included by default
globIgnores ["**/node_modules/**/*"] Files to exclude. Setting this replaces the default
globFollow true Follow symlinks
templatedURLs — Map server-rendered URLs (e.g. /) to the files (glob array) or a string that determines their revision
dontCacheBustURLsMatching — RegExp for URLs already versioned by file name; matching entries get revision: null and skip cache: "reload"
maximumFileSizeToCacheInBytes 2097152 (2 MiB) Larger matches are dropped with a warning, not an error
modifyURLPrefix — String prefix rewrites, e.g. {"dist/": "/"}
manifestTransforms — Functions (entries, compilation?) => {manifest, warnings} applied after the two options above
additionalManifestEntries — Extra string or {url, revision, integrity?} entries; plain strings trigger a warning, because they have no revision

Revisions are the MD5 hex digest of each file's contents (or of the concatenated contents for a templatedURLs entry). MD5 is fine here, because the hash only detects changes and has no security role. Because the revision depends only on content, rebuilding identical sources yields a byte-identical manifest. That keeps update checks quiet when nothing changed.

A realistic manifestTransforms function that drops files a CDN serves elsewhere and prefixes the rest:

workbox/transforms.js
/**
 * @param {Array<{url: string, revision: string|null, size: number}>} entries
 * @returns {{manifest: typeof entries, warnings: string[]}}
 */
export function dropSourceMapsAndPrefix(entries) {
  const warnings = [];
  const manifest = [];
  for (const entry of entries) {
    if (entry.url.endsWith(".map")) continue; // never precache source maps
    if (entry.size > 500 * 1024) {
      // Keep them, but surface them in CI output.
      warnings.push(`${entry.url} is ${Math.round(entry.size / 1024)} KiB`);
    }
    manifest.push({ ...entry, url: `/app/${entry.url}` });
  }
  return { manifest, warnings };
}

The workbox-cli

workbox-cli wraps workbox-build and adds an interactive wizard. It needs Node.js 20 or later for 7.4.x.

Terminal
npm install --save-dev workbox-cli
npx workbox wizard                    # generateSW-oriented questions
npx workbox wizard --injectManifest   # asks for swSrc as well
npx workbox generateSW workbox-config.cjs
npx workbox injectManifest workbox-config.cjs --watch
npx workbox copyLibraries dist/       # self-host the runtime for workbox-sw

The wizard asks exactly these questions, then writes a config file:

  1. What is the root of your web app (i.e. which directory do you deploy)? It offers the subdirectories it finds, and the answer becomes globDirectory.
  2. Which file types would you like to precache? A checkbox list built from the extensions actually found in that directory. The answer becomes globPatterns.
  3. Where would you like your service worker file to be saved? Becomes swDest, default <root>/sw.js.
  4. With --injectManifest only: Where's your existing service worker file? Becomes swSrc.
  5. Does your web app manifest include search parameter(s) in the start_url, other than utm_ or fbclid (like ?source=pwa)? If you answer yes, the parameters are added to ignoreURLParametersMatching, so the precached start_url still matches the launch URL.
  6. Where would you like to save these configuration options? Default workbox-config.js.

The config file is loaded as CommonJS (module.exports = {...}). In a package with "type": "module", name it workbox-config.cjs. --watch rebuilds the worker whenever a file in the precache changes. It is meant for local development, not for CI. Here is a production-grade config for a static site:

workbox-config.cjs
/** @type {import('workbox-build').GenerateSWOptions} */
module.exports = {
  globDirectory: "dist/",
  globPatterns: ["**/*.{html,js,css,woff2,svg,webmanifest}"],
  globIgnores: ["**/node_modules/**/*", "**/*.map", "admin/**"],
  // Vite/webpack-style content hashes in file names: no revision needed.
  dontCacheBustURLsMatching: /\.[0-9a-f]{8,}\./,
  swDest: "dist/sw.js",
  // Prompt-to-update flow: do NOT skip waiting automatically.
  skipWaiting: false,
  clientsClaim: false,
  cleanupOutdatedCaches: true,
  navigationPreload: true,
  ignoreURLParametersMatching: [/^utm_/, /^fbclid$/, /^source$/],
  runtimeCaching: [
    {
      // Pages that aren't precached: network first, cached copy offline.
      urlPattern: ({ request }) => request.mode === "navigate",
      handler: "NetworkFirst",
      options: {
        cacheName: "pages",
        networkTimeoutSeconds: 4,
        expiration: { maxEntries: 50 },
        precacheFallback: { fallbackURL: "/offline.html" },
      },
    },
    {
      urlPattern: ({ request }) => request.destination === "image",
      handler: "CacheFirst",
      options: {
        cacheName: "images",
        expiration: {
          maxEntries: 200,
          maxAgeSeconds: 30 * 24 * 60 * 60,
          purgeOnQuotaError: true,
        },
      },
    },
  ],
};

/offline.html must match globPatterns so that it is in the precache, because precacheFallback looks it up with matchPrecache().

workbox-build: the Node API

Use workbox-build directly when you need the build to fail on warnings, run inside a custom script, or come after another bundling step. All three functions return a promise for {count, size, warnings}. generateSW and injectManifest also include filePaths, the list of files written, and getManifest includes manifestEntries instead.

scripts/build-sw.mjs
import { generateSW } from "workbox-build";
import prettyBytes from "pretty-bytes";

const PRECACHE_BUDGET = 1.5 * 1024 * 1024; // fail CI above 1.5 MiB

try {
  const { count, size, warnings, filePaths } = await generateSW({
    globDirectory: "dist",
    globPatterns: ["**/*.{html,js,css,woff2}"],
    swDest: "dist/sw.js",
    cleanupOutdatedCaches: true,
    sourcemap: false,
    // Inline to ship one file (useful on hosts that rewrite unknown paths).
    inlineWorkboxRuntime: true,
    mode: "production",
  });

  if (warnings.length > 0) {
    // Oversized files, strings without revisions, etc. Treat as errors.
    console.error("Workbox warnings:\n" + warnings.join("\n"));
    process.exitCode = 1;
  }
  if (size > PRECACHE_BUDGET) {
    console.error(`Precache is ${prettyBytes(size)} (budget ${prettyBytes(PRECACHE_BUDGET)})`);
    process.exitCode = 1;
  }
  console.log(`Precaching ${count} files, ${prettyBytes(size)}. Wrote: ${filePaths.join(", ")}`);
} catch (error) {
  // Schema validation errors land here with a readable message.
  console.error(error.message);
  process.exit(1);
}

Options are validated against a JSON schema before any work happens. A typo such as globPattern fails immediately, with an error naming the unknown property. getManifest() is useful when you generate the worker some other way, for example with a template engine, and only need the list:

scripts/manifest-only.mjs
import { getManifest } from "workbox-build";

const { manifestEntries, count, size, warnings } = await getManifest({
  globDirectory: "dist",
  globPatterns: ["**/*.{js,css,html}"],
});
console.log(JSON.stringify(manifestEntries, null, 2));

workbox-webpack-plugin

The webpack plugin exports two classes, GenerateSW and InjectManifest. Version 7.4.1 declares a peer dependency on webpack: ^4.4.0 || ^5.91.0. It builds the manifest from the compilation's assets, not from a directory glob. So globPatterns and globDirectory don't exist here. You filter with webpack-style conditions instead:

Option Default Applies to
include — (all assets) Both. webpack condition semantics
exclude [/\.map$/, /^manifest.*\.js$/] Both. Setting it replaces the default, so re-add /\.map$/
chunks / excludeChunks — Both. Filter by chunk name
swDest "service-worker.js" (GenerateSW); swSrc's file name with its extension replaced by .js, e.g. src/sw.ts → sw.js (InjectManifest) Asset name relative to output.path
importScriptsViaChunks — GenerateSW: emit named chunks and importScripts() them
compileSrc true InjectManifest: compile swSrc with webpack; false just injects (e.g. into JSON)
webpackCompilationPlugins — InjectManifest: plugins for the child compilation (e.g. DefinePlugin)
mode the compilation's mode Both
webpack.config.mjs
import path from "node:path";
import webpack from "webpack";
import HtmlWebpackPlugin from "html-webpack-plugin";
import { InjectManifest } from "workbox-webpack-plugin";

export default (env, argv) => ({
  entry: { main: "./src/index.js" },
  output: {
    path: path.resolve("dist"),
    filename: "[name].[contenthash:8].js",
    publicPath: "/",
    clean: true,
  },
  plugins: [
    new HtmlWebpackPlugin({ template: "src/index.html" }),
    new InjectManifest({
      swSrc: "./src/sw.js",
      swDest: "sw.js",
      // Content-hashed names are already unique: skip cache busting.
      dontCacheBustURLsMatching: /\.[0-9a-f]{8}\./,
      exclude: [/\.map$/, /^manifest.*\.js$/, /\.LICENSE\.txt$/],
      maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
      webpackCompilationPlugins: [
        new webpack.DefinePlugin({
          __BUILD_ID__: JSON.stringify(process.env.GIT_SHA ?? "dev"),
        }),
      ],
    }),
  ],
});

In webpack --watch or webpack serve, the plugin runs again on every rebuild. From the second run on, webpack gets the warning "InjectManifest has been called multiple times, perhaps due to running webpack in --watch mode. The precache manifest generated after the first call may be inaccurate!". The usual fix is to add the plugin only for production builds (argv.mode === "production") and to develop without a service worker, or with one that doesn't precache. The pitfalls page explains why a precaching worker in development causes confusing stale-asset bugs.

Routing requests

registerRoute() and how matching works

registerRoute(
  capture: string | RegExp | RouteMatchCallback | Route,
  handler?: RouteHandler,       // Strategy instance or ({url, request, event, params}) => Promise<Response>
  method?: "GET" | "POST" | "PUT" | "DELETE" | "HEAD" | "PATCH", // default "GET"
): Route

The router processes each fetch event as follows (from Router.handleRequest()):

  1. Non-HTTP URLs are skipped. If url.protocol doesn't start with http, the router returns undefined, and the event gets no response from Workbox.
  2. Routes are tested in registration order, filtered by method. The first route whose match callback returns a truthy value wins, and later routes are never consulted. Register specific routes before broad ones.
  3. The match return value becomes params. If the callback returns a non-empty array or object, it is passed to the handler as params. RegExpRoute passes the capture groups (result.slice(1)).
  4. No match falls through to the default handler. Without a default handler for that method, handleRequest() returns undefined and event.respondWith() is never called, so the browser performs the request as if there were no service worker.
  5. A rejected handler promise goes to the catch handlers. The route's own catchHandler runs first, then the global one from setCatchHandler(). If neither exists, the rejection propagates, and the page sees a network error.

The three capture forms behave differently:

Capture Semantics Gotcha
"/api/config.json" Exact url.href match after resolving against location.href Not a pattern. In development, Workbox warns if the string contains *, :, ? or +, because Express-style wildcards are not supported
/\/api\/.*\.json$/ regExp.exec(url.href) Cross-origin URLs only match if the match starts at index 0, so /\.png$/ never matches https://cdn.example/x.png. Write /^https:\/\/cdn\.example\/.*\.png$/
({url, request, event, sameOrigin}) => boolean Arbitrary logic event isn't always a FetchEvent: for CACHE_URLS it is the ExtendableMessageEvent, and code calling router.handleRequest() directly can pass anything. Don't read event.clientId or event.preloadResponse without checking

Match callbacks run synchronously for every request the worker sees, so keep them cheap. Test request.destination, request.mode or url.pathname.startsWith(). Avoid regular expressions with catastrophic backtracking. The generateSW documentation carries the same warning for navigateFallbackAllowlist and navigateFallbackDenylist.

NavigationRoute(handler, {allowlist = [/./], denylist = []}) matches only requests with request.mode === "navigate". It then tests url.pathname + url.search (never the origin or the hash). The denylist is checked first, and any match rejects the request. Then any allowlist match accepts it. Pair it with createHandlerBoundToURL("/index.html") so that every in-app navigation gets the precached shell:

src/sw.js (excerpt)
import { NavigationRoute, registerRoute } from "workbox-routing";
import { createHandlerBoundToURL } from "workbox-precaching";

registerRoute(
  new NavigationRoute(createHandlerBoundToURL("/index.html"), {
    denylist: [
      /^\/api\//,        // API calls that happen to be navigations (downloads)
      /^\/auth\//,       // OAuth redirects must reach the server
      /\/[^/?]+\.[^/]+$/, // anything that looks like a file: /sitemap.xml, /robots.txt
    ],
  }),
);

createHandlerBoundToURL() throws non-precached-url at call time (in production builds too) if the URL isn't in the precache list, so it must run after precacheAndRoute(). That's intentional: it catches misconfiguration during worker evaluation, not in the middle of a user's navigation. The SPA vs MPA page discusses when a shell fallback is appropriate at all.

Default and catch handlers

src/sw.js (excerpt)
import { setDefaultHandler, setCatchHandler } from "workbox-routing";
import { NetworkOnly } from "workbox-strategies";
import { matchPrecache } from "workbox-precaching";

// Every GET that no route matched: go to the network, but through Workbox,
// so the catch handler below also applies to it.
setDefaultHandler(new NetworkOnly());

// Global last resort for any route whose handler rejected.
setCatchHandler(async ({ request }) => {
  switch (request.destination) {
    case "document":
      return (await matchPrecache("/offline.html")) ?? Response.error();
    case "image":
      return (await matchPrecache("/img/offline.svg")) ?? Response.error();
    default:
      return Response.error();
  }
});

A default handler changes behavior in a way people often miss. Without one, unmatched requests never reach the service worker's respondWith(). With one, every unmatched GET, including cross-origin analytics beacons and media, now goes through Workbox. That costs a little latency, and those requests become subject to your catch handler. Response.error() produces a network error in the page, which is the correct "I have nothing" answer. The offline UX page covers designing the fallbacks themselves.

Caching URLs on demand with CACHE_URLS

The default router also listens for a message whose data is {type: "CACHE_URLS", payload: {urlsToCache: [...]}}. Each entry is a URL string or a [url, requestInit] tuple. The router runs every URL through the route that would handle it, so the response lands in whatever cache that route's strategy uses. If the message carries a MessagePort, the router posts true to it when all the URLs are done. This lets a page warm runtime caches after load without knowing any cache names:

src/main.js (excerpt)
const reg = await navigator.serviceWorker.ready;
const urlsToCache = [location.href, ...performance.getEntriesByType("resource").map((e) => e.name)];
reg.active?.postMessage({ type: "CACHE_URLS", payload: { urlsToCache } });

Strategies

The five built-in strategies, exactly

The behavior below comes straight from the 7.4.1 source. The caching strategies page explains when to use each one conceptually.

Strategy Read path Write path Default cacheability (without your own cacheWillUpdate) Extra options
CacheFirst cacheMatch(); on miss, fetchAndCachePut() On miss only Only status === 200 —
CacheOnly cacheMatch() only Never n/a —
NetworkFirst fetchAndCachePut(); on error, or on timeout if set, cacheMatch() Every successful fetch 200 or opaque (0), via a built-in plugin added with unshift networkTimeoutSeconds
NetworkOnly fetch() only Never n/a networkTimeoutSeconds (rejects on timeout)
StaleWhileRevalidate Starts fetchAndCachePut() first, then cacheMatch(); returns the cache hit, or waits for the network on a miss Every successful fetch 200 or opaque (0) —

A few precise consequences:

  • Status 0 is cached by NetworkFirst and StaleWhileRevalidate. An opaque cross-origin response can hide a 404 or a 500, and it takes up padded quota in Chromium (several megabytes per entry). If you route cross-origin requests to these strategies, add new CacheableResponsePlugin({statuses: [200]}) unless you really want opaque entries. Adding any plugin with cacheWillUpdate replaces the built-in rule entirely.
  • The NetworkFirst timeout doesn't cancel the fetch. When networkTimeoutSeconds elapses, the strategy resolves with the cached response, if one exists. The network request keeps running, and its response still updates the cache (the handler waitUntil()s it). If the cache is empty at timeout, the strategy keeps waiting for the network.
  • Errors surface as WorkboxError('no-response'). Every built-in strategy throws it when it ends up with no response. Strategy._getResponse() does the same for custom strategies that return undefined or a Response.error(). That throw is what triggers handlerDidError plugins and catch handlers.
  • Default cache names depend on scope. A strategy without cacheName uses workbox-runtime-<registration.scope>. The precache uses workbox-precache-v2-<scope>, and Google Analytics uses workbox-googleAnalytics-<scope>. Change the workbox prefix with setCacheNameDetails({prefix}), or with cacheId in generateSW. cacheId is useful when several apps share http://localhost:8080 in development.

Strategy options

Every strategy constructor accepts {cacheName, plugins, fetchOptions, matchOptions}:

  • cacheName: the Cache Storage name. Give every route its own. Expiration and cleanup operate per cache.
  • plugins: an array of plugin objects, run in array order for each lifecycle callback. Advanced Workbox documents all twelve callbacks.
  • fetchOptions: a RequestInit passed to fetch(). It is ignored for navigation requests (request.mode === "navigate"). Under the Fetch specification, passing any non-empty init together with a navigation Request downgrades its mode from navigate to same-origin.
  • matchOptions: CacheQueryOptions (ignoreSearch, ignoreVary, ignoreMethod) used for caches.match(). In development, Workbox logs a hint to set ignoreVary: true whenever it caches a response carrying a Vary header.
src/sw.js (excerpt)
import { registerRoute } from "workbox-routing";
import { NetworkFirst, StaleWhileRevalidate } from "workbox-strategies";
import { ExpirationPlugin } from "workbox-expiration";
import { CacheableResponsePlugin } from "workbox-cacheable-response";

// JSON API: fresh when possible, cached copy after 3 s or when offline.
registerRoute(
  ({ url, sameOrigin }) => sameOrigin && url.pathname.startsWith("/api/"),
  new NetworkFirst({
    cacheName: "api-v1",
    networkTimeoutSeconds: 3,
    fetchOptions: { credentials: "same-origin" },
    matchOptions: { ignoreVary: true },
    plugins: [
      new CacheableResponsePlugin({ statuses: [200] }),
      new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 24 * 60 * 60 }),
    ],
  }),
);

// Third-party stylesheet: only cache successful CORS responses.
registerRoute(
  ({ url }) => url.origin === "https://cdn.example.com" && url.pathname.endsWith(".css"),
  new StaleWhileRevalidate({
    cacheName: "cdn-css",
    plugins: [new CacheableResponsePlugin({ statuses: [200] })],
  }),
);

Using a strategy outside the router

Strategy instances are plain objects with handle() and handleAll(), so you can call them from your own fetch listener or from a message handler:

src/sw.js (excerpt)
import { StaleWhileRevalidate } from "workbox-strategies";

const avatars = new StaleWhileRevalidate({ cacheName: "avatars" });

self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);
  if (url.pathname.startsWith("/avatars/")) {
    // handleAll() returns [responseDone, handlerDone]:
    // responseDone -> the Response for respondWith()
    // handlerDone  -> resolves when all cache writes and plugin work finish
    const [responseDone, handlerDone] = avatars.handleAll(event);
    event.respondWith(responseDone);
    event.waitUntil(handlerDone);
  }
});

handle() is the same call, but returns only responseDone. StrategyHandler's constructor already calls event.waitUntil() on an internal promise that resolves when the handler is destroyed. So even with handle(), the worker stays alive until the background cache writes finish. handleAll() exists for callers who need to know when that work is done, for example warmStrategyCache.

That synchronous event.waitUntil() call in the constructor has a consequence: create the handler (call handle()/handleAll()) before your listener yields. The Service Workers specification only allows waitUntil() while the event is still being dispatched or while an earlier lifetime promise (including the one passed to respondWith()) is pending. If a fetch listener first awaits something (an IndexedDB lookup, say) and only then calls strategy.handle(event) without having called respondWith(), the constructor throws an InvalidStateError. Call event.respondWith() synchronously with an async function that does the lookup and then calls the strategy, as the router does.

Built-in plugins you will use on every route

Plugins are plain objects with lifecycle callbacks. Twelve callbacks exist; Advanced Workbox walks through all of them and shows how to write your own. Two plugins belong on almost every runtime route.

ExpirationPlugin

new ExpirationPlugin({maxEntries?, maxAgeSeconds?, matchOptions?, purgeOnQuotaError?}). At least one of maxEntries or maxAgeSeconds is required. Only development builds enforce that: the constructor throws max-entries-or-age-required. A production build accepts {} silently and then never evicts anything, so a typo such as maxEntry ships unnoticed unless you test with a development build. What it actually does:

  • Metadata lives in IndexedDB, in a database named workbox-expiration (object store cache-entries, indexed by timestamp), with one record per cached URL and cache name.
  • cacheDidUpdate writes Date.now() for the URL and then runs expiration for that cache.
  • cachedResponseWillBeUsed also writes Date.now() for the URL (inside event.waitUntil()), kicks off expiration in the background, and returns null (treated as a cache miss) if the response's Date header is older than maxAgeSeconds. Responses without a parseable Date header are treated as fresh at read time.
  • Eviction is least-recently-used, not first-in-first-out. Because reads refresh the timestamp, maxEntries keeps the most recently read or written entries, and maxAgeSeconds in the IndexedDB sweep means "not touched for this long". The Date header check is the only part that measures the age of the response itself.
  • Expiration is lazy. Nothing runs on a timer; entries are only evaluated when the cache is read or written through the strategy. A cache nobody touches never shrinks. Call new CacheExpiration(cacheName, config).expireEntries() from activate if you need a sweep on every update.
  • It refuses the default runtime cache. If the strategy has no explicit cacheName, the plugin throws expire-custom-caches-only (in production builds too), because deleting entries from a shared default cache would affect other routes. The check runs lazily, the first time a callback needs the cache's expiration state, so the error shows up on the first matching request or cache write, not when the worker starts.
  • purgeOnQuotaError: true registers a callback with registerQuotaErrorCallback(). When any Workbox cache.put() throws QuotaExceededError, the plugin deletes its whole cache and its metadata. Use it for caches that are cheap to rebuild (images, avatars), never for data the user would miss.

CacheableResponsePlugin

new CacheableResponsePlugin({statuses?: number[], headers?: Record<string, string>}) implements cacheWillUpdate. A response is cacheable only if its status is in statuses and every header in headers has exactly the given value. When both are set, both must pass. Typical uses:

src/sw.js (excerpt)
import { CacheableResponsePlugin } from "workbox-cacheable-response";

// Opaque responses allowed (third-party images without CORS):
new CacheableResponsePlugin({ statuses: [0, 200] });

// Only cache API responses the server explicitly marks as cacheable:
new CacheableResponsePlugin({ statuses: [200], headers: { "x-sw-cacheable": "true" } });

Remember the interaction described earlier: adding this plugin to NetworkFirst or StaleWhileRevalidate replaces their built-in "200 or opaque" rule, and adding it to CacheFirst replaces the "200 only" rule.

The other plugins at a glance

Plugin Callback(s) One-line summary Details
BackgroundSyncPlugin(name, options) fetchDidFail Stores requests that failed with a network error in IndexedDB and replays them later Advanced Workbox, Background Sync
BroadcastUpdatePlugin(options) cacheDidUpdate Posts {type: "CACHE_UPDATED"} to windows when a cached response changed Advanced Workbox
RangeRequestsPlugin() cachedResponseWillBeUsed Slices a full cached body into a 206 for Range requests (media) Advanced Workbox
PrecacheFallbackPlugin({fallbackURL}) handlerDidError Returns a precached URL when the strategy fails Offline UX

Precaching with Workbox

The precaching page covers the mechanism in depth, including Workbox's storage format, so this section only lists the API and its defaults.

src/sw.js (excerpt)
import { precacheAndRoute, cleanupOutdatedCaches } from "workbox-precaching";

precacheAndRoute(self.__WB_MANIFEST, {
  directoryIndex: "index.html",                     // default
  ignoreURLParametersMatching: [/^utm_/, /^fbclid$/], // default
  cleanURLs: true,                                  // default: /about also tries /about.html
  urlManipulation: ({ url }) => {
    // Extra candidates to try, in order, after the built-in ones.
    if (url.pathname.startsWith("/app/")) return [new URL("/app/index.html", url)];
    return [];
  },
});
cleanupOutdatedCaches();

The details that matter in production:

  • Keys. Each entry is stored under a cache key url?__WB_REVISION__=<revision> when it has a revision, or under the plain URL when revision is null (hashed file names). A request is looked up by generating candidate URLs: the exact URL; the URL with ignoreURLParametersMatching parameters removed; plus directoryIndex if the path ends in /; plus .html if cleanURLs; and finally whatever urlManipulation returns.
  • Install. On install, the entries are processed one at a time, not in parallel (Workbox issue #2528), and an entry whose cache key is already present is skipped without a network request. Revisioned entries use cache: "reload" to bypass the HTTP cache; all entries use credentials: "same-origin" and carry integrity when the manifest entry specifies it. Any response with a status of 400 or more fails the install, which keeps the previous worker in control.
  • Activate. Keys that are no longer in the manifest are deleted from the precache during activate, so the old worker keeps serving its own files until it's replaced.
  • Redirects. A redirected response is re-wrapped with copyResponse() before it is stored, because the browser rejects a redirected response for a navigation.
  • Network fallback. On a precache miss, the precache route goes to the network (fallbackToNetwork defaults to true) and, in development, logs a warning. That happens when the cache was evicted or cleared by the user.
  • Cleanup of old precaches. cleanupOutdatedCaches() adds an activate listener that deletes every cache whose name contains -precache- and the current registration.scope, except the current precache. It exists for precaches left by older, incompatible Workbox versions. It also removes a precache left behind when you change cacheId. It does nothing for your own old runtime caches: delete those yourself in activate.
  • Deduplication. precacheAndRoute() throws if two entries share a URL but have different revisions (add-to-cache-list-conflicting-entries). That usually means the manifest was built from two overlapping globs.

matchPrecache(url) reads from the precache by original URL, without you knowing the revision. getCacheKeyForURL(url) returns the revisioned key when you need to call caches.match() yourself.

Navigation preload lets the browser start the navigation request while the service worker boots. Workbox handles the two halves separately:

src/sw.js (excerpt)
import * as navigationPreload from "workbox-navigation-preload";
import { registerRoute, NavigationRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies";

// 1. Turn it on during activate (no-op where unsupported).
navigationPreload.enable(); // optional header value: enable("v2")

// 2. Any strategy that fetches uses the preload response automatically:
//    StrategyHandler.fetch() awaits event.preloadResponse for navigate-mode
//    requests and returns it if it is defined, instead of issuing a new fetch.
registerRoute(
  new NavigationRoute(
    new NetworkFirst({ cacheName: "pages", networkTimeoutSeconds: 3 }),
  ),
);

Two edge cases come from this design. First, plugins' requestWillFetch and fetchDidSucceed callbacks are skipped when the preload response is used, because StrategyHandler.fetch() returns it before running them. Second, never enable preload together with a NavigationRoute bound to the precached shell (createHandlerBoundToURL). That handler never fetches, so every preload request is wasted server work. generateSW's navigationPreload: true is documented as requiring a runtimeCaching route for navigations for exactly this reason.

workbox-window: registration and updates from the page

workbox-window is a small module for the page, with no dependencies. It wraps navigator.serviceWorker.register() and turns the raw updatefound/statechange/controllerchange events into a smaller set of events that know whether a worker came from this registration call. The updating service workers page explains the underlying algorithm. This section is the API reference.

Constructor, register() and update()

new Workbox(scriptURL: string | TrustedScriptURL, registerOptions?: RegistrationOptions)
wb.register({immediate = false} = {}): Promise<ServiceWorkerRegistration | undefined>
wb.update(): Promise<void>                 // registration.update()
wb.getSW(): Promise<ServiceWorker>         // the worker this instance "owns"
wb.active: Promise<ServiceWorker>          // resolves when own SW reaches activating/activated
wb.controlling: Promise<ServiceWorker>     // resolves when own SW controls the page
wb.messageSW(data: object): Promise<any>   // postMessage + MessageChannel reply
wb.messageSkipWaiting(): void              // posts {type: "SKIP_WAITING"} to registration.waiting
  • register() waits for load unless immediate: true. Registering after load keeps the worker's install-time precache downloads from competing with the page's own resources. Use immediate only when the page is already loaded or you know the precache is small.
  • One registration per instance. In development, calling register() twice logs an error and returns undefined. Create a new Workbox instance instead.
  • messageSW() never times out. It resolves with the first message posted back on the transferred port. If the worker never replies, the promise stays pending forever. Wrap it in Promise.race() with a timer if you rely on the reply.

Events and their properties

Event When it fires Useful properties
installed The worker's statechange reaches installed sw, isUpdate, isExternal, originalEvent
waiting 200 ms after installed, if the worker is still registration.waiting (the 200 ms delay filters out workers that call skipWaiting() during install); also fired right after register() if a matching worker was already waiting sw, isUpdate, isExternal, wasWaitingBeforeRegister
activating, activated, redundant Corresponding statechange sw, isUpdate, isExternal
controlling navigator.serviceWorker controllerchange sw, isExternal, isUpdate
message A message from a worker this instance owns (now or previously) data, ports, sw, originalEvent

isUpdate is true when any worker controlled the page at the moment register() ran, which means this isn't a first install. isExternal needs more care. When updatefound fires, workbox-window treats the installing worker as external if any of these is true:

  1. This isn't the first updatefound this instance has seen.
  2. The installing worker's script URL differs from the one passed to the constructor.
  3. More than 60 seconds have passed since register() was called.

The third rule means that a long-lived tab which calls wb.update() every hour sees every update as isExternal: true, even though it's the same sw.js. After the first external worker, the instance also stops listening for updatefound. Later updates in the same tab only show up through controlling, or when you inspect registration.waiting yourself. A robust "new version available" prompt therefore does four things: handles waiting whatever the value of isExternal, adds its own updatefound listener on the registration for updates the instance no longer tracks, checks registration.waiting after its own update checks, and reloads on controlling only after the user has opted in. Because those paths overlap, it also deduplicates by worker so the user never sees two prompts for one update.

A complete prompt-to-update registration

src/register-sw.js
import { Workbox } from "workbox-window";

/**
 * Registers /sw.js and wires up a "new version available" prompt.
 * @param {(onAccept: () => void) => void} showPrompt  UI hook: call onAccept() when the user clicks "Reload".
 */
export function registerServiceWorker(showPrompt) {
  if (!("serviceWorker" in navigator)) return;

  const wb = new Workbox("/sw.js", { scope: "/", updateViaCache: "none" });
  let registration;
  let userAccepted = false;
  let promptedFor = null; // the waiting worker the prompt is currently shown for

  const promptFor = (waitingSW) => {
    // Several paths below can report the same waiting worker; prompt once.
    if (!waitingSW || waitingSW === promptedFor) return;
    promptedFor = waitingSW;
    showPrompt(() => {
      userAccepted = true;
      // Matches the SKIP_WAITING listener generateSW emits (or the one you add
      // in an injectManifest worker).
      waitingSW.postMessage({ type: "SKIP_WAITING" });
    });
  };

  // Fires for both "own" and external updates; don't filter on isExternal.
  wb.addEventListener("waiting", (event) => promptFor(event.sw));

  // Reload only after the user agreed; otherwise a skipWaiting() from another
  // tab would yank this page out from under the user.
  let refreshing = false; // guard against a second controllerchange
  wb.addEventListener("controlling", () => {
    if (!userAccepted || refreshing) return;
    refreshing = true;
    window.location.reload();
  });

  wb.register()
    .then((reg) => {
      if (!reg) return;
      registration = reg;
      // workbox-window stops listening for updatefound after the first
      // "external" update, so track later installs on the registration itself.
      reg.addEventListener("updatefound", () => {
        const installing = reg.installing;
        installing?.addEventListener("statechange", () => {
          // "installed" + an existing controller = an update is now waiting.
          if (installing.state === "installed" && navigator.serviceWorker.controller) {
            promptFor(reg.waiting);
          }
        });
      });
    })
    .catch((error) => {
      // Registration failures (404, MIME type, SecurityError) must not break the app.
      console.error("Service worker registration failed:", error);
    });

  // Periodic update checks for long-lived sessions (installed PWAs stay open for days).
  const HOUR = 60 * 60 * 1000;
  setInterval(async () => {
    if (!registration || document.visibilityState !== "visible") return;
    try {
      // Resolves when the update check finishes. A new worker found by it is
      // still *installing* at this point; the updatefound listener above
      // prompts once it reaches "installed".
      await wb.update();
      promptFor(registration.waiting); // covers a worker that was already waiting
    } catch {
      // Offline or server error: try again next interval.
    }
  }, HOUR);
}

The updating service workers page compares this prompt pattern with auto-reload, reload-on-next-navigation and kill-switch strategies.

Complete configurations

The four configurations below cover most real deployments. Each is complete: copy it, adjust the paths, and it builds.

Configuration 1: static or multi-page site with workbox-cli

A documentation site or marketing site with no bundler: generateSW from the CLI, pages network-first, assets precached, plus a tiny extra file for a push listener pulled in through importScripts.

workbox-config.cjs
/** @type {import('workbox-build').GenerateSWOptions} */
module.exports = {
  globDirectory: "public/",
  globPatterns: ["**/*.{css,js,woff2,svg}", "offline.html"],
  swDest: "public/sw.js",
  importScripts: ["/sw-push.js"],      // your own classic script, deployed separately
  cleanupOutdatedCaches: true,
  clientsClaim: true,
  skipWaiting: true,                   // MPA: pages reload on every navigation anyway
  navigationPreload: true,
  runtimeCaching: [
    {
      urlPattern: ({ request }) => request.mode === "navigate",
      handler: "NetworkFirst",
      options: {
        cacheName: "pages",
        networkTimeoutSeconds: 3,
        expiration: { maxEntries: 100, maxAgeSeconds: 7 * 24 * 60 * 60 },
        cacheableResponse: { statuses: [200] },
        precacheFallback: { fallbackURL: "/offline.html" },
      },
    },
    {
      urlPattern: ({ request }) => request.destination === "image",
      handler: "StaleWhileRevalidate",
      options: {
        cacheName: "images",
        expiration: { maxEntries: 150, purgeOnQuotaError: true },
      },
    },
  ],
};
public/sw-push.js
// Loaded by the generated worker via importScripts(); runs as a classic script.
self.addEventListener("push", (event) => {
  const data = event.data ? event.data.json() : { title: "Update", body: "" };
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: "/icons/192.png",
      data: { url: data.url || "/" },
    }),
  );
});

self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  event.waitUntil(self.clients.openWindow(event.notification.data.url));
});

Two warnings. Everything imported with importScripts must be available at install time: the file is fetched during the first evaluation and cached with the worker. And changing sw-push.js alone triggers an update, because imported scripts are byte-compared too, but only when their URL stays the same. With the default updateViaCache: "imports", that update check may be answered from the HTTP cache, so serve sw-push.js with Cache-Control: no-cache or register with updateViaCache: "none". See updating.

skipWaiting: true with clientsClaim: true is reasonable for a multi-page site, where every navigation loads fresh HTML. For a single-page app it risks running old JavaScript against a new worker and new precache, so use the prompt pattern above instead.

Configuration 2: single-page app with webpack GenerateSW

webpack.config.mjs
import path from "node:path";
import HtmlWebpackPlugin from "html-webpack-plugin";
import { GenerateSW } from "workbox-webpack-plugin";

export default (env, argv) => {
  const isProd = argv.mode === "production";
  return {
    entry: "./src/main.js",
    output: {
      path: path.resolve("dist"),
      filename: "assets/[name].[contenthash:8].js",
      assetModuleFilename: "assets/[name].[contenthash:8][ext]",
      publicPath: "/",
      clean: true,
    },
    plugins: [
      new HtmlWebpackPlugin({ template: "src/index.html" }),
      // Only in production: avoids the --watch "called multiple times" problem
      // and stale-asset confusion in development.
      ...(isProd
        ? [
            new GenerateSW({
              swDest: "sw.js",
              dontCacheBustURLsMatching: /\.[0-9a-f]{8}\./,
              exclude: [/\.map$/, /^manifest.*\.js$/, /\.LICENSE\.txt$/],
              cleanupOutdatedCaches: true,
              navigateFallback: "/index.html",
              navigateFallbackDenylist: [/^\/api\//, /^\/auth\//, /\/[^/?]+\.[^/]+$/],
              runtimeCaching: [
                {
                  urlPattern: ({ url }) => url.pathname.startsWith("/api/"),
                  handler: "NetworkFirst",
                  method: "GET",
                  options: {
                    cacheName: "api",
                    networkTimeoutSeconds: 4,
                    expiration: { maxEntries: 60, maxAgeSeconds: 24 * 60 * 60 },
                    cacheableResponse: { statuses: [200] },
                  },
                },
                {
                  urlPattern: ({ url }) => url.pathname.startsWith("/api/"),
                  handler: "NetworkOnly",
                  method: "POST",
                  options: {
                    backgroundSync: {
                      name: "api-post-queue",
                      options: { maxRetentionTime: 24 * 60 }, // minutes
                    },
                  },
                },
              ],
            }),
          ]
        : []),
    ],
  };
};

index.html is in the webpack assets (emitted by HtmlWebpackPlugin), so it's in the manifest, and navigateFallback can bind to it. The POST route shows the backgroundSync shortcut option: generateSW adds a BackgroundSyncPlugin to a NetworkOnly strategy, and failed POSTs are queued. Remember that replay only happens on network errors, not on 4xx/5xx responses; Advanced Workbox explains how to change that.

Configuration 3: injectManifest with a custom worker and esbuild

This is the setup most full-featured PWAs end up with: a hand-written worker using Workbox modules, bundled with esbuild, with the manifest injected afterwards.

src/sw.js
import { clientsClaim, setCacheNameDetails } from "workbox-core";
import {
  precacheAndRoute,
  cleanupOutdatedCaches,
  createHandlerBoundToURL,
  matchPrecache,
} from "workbox-precaching";
import {
  registerRoute,
  NavigationRoute,
  setCatchHandler,
} from "workbox-routing";
import {
  CacheFirst,
  NetworkFirst,
  StaleWhileRevalidate,
} from "workbox-strategies";
import { ExpirationPlugin } from "workbox-expiration";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
import * as navigationPreload from "workbox-navigation-preload";

setCacheNameDetails({ prefix: "myapp" });

// --- Update flow: wait for the page to ask. ----------------------------------
self.addEventListener("message", (event) => {
  if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
});
clientsClaim(); // take control of uncontrolled clients on first install

// --- Precache: the injection point must appear exactly once in this file. ----
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();

// --- Remove runtime caches from older app versions. --------------------------
const RUNTIME_CACHES = new Set(["myapp-api-v2", "myapp-images-v1", "myapp-fonts-v1"]);
self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      for (const name of await caches.keys()) {
        const ours = name.startsWith("myapp-") && !name.startsWith("myapp-precache");
        if (ours && !RUNTIME_CACHES.has(name)) await caches.delete(name);
      }
    })(),
  );
});

// --- App shell for in-app navigations. -----------------------------------------
registerRoute(
  new NavigationRoute(createHandlerBoundToURL("/index.html"), {
    denylist: [/^\/api\//, /^\/auth\//, /\/[^/?]+\.[^/]+$/],
  }),
);

// --- Runtime routes (specific before general). --------------------------------
registerRoute(
  ({ url, sameOrigin }) => sameOrigin && url.pathname.startsWith("/api/"),
  new NetworkFirst({
    cacheName: "myapp-api-v2",
    networkTimeoutSeconds: 4,
    plugins: [
      new CacheableResponsePlugin({ statuses: [200] }),
      new ExpirationPlugin({ maxEntries: 80, maxAgeSeconds: 24 * 60 * 60 }),
    ],
  }),
);

registerRoute(
  ({ request }) => request.destination === "image",
  new CacheFirst({
    cacheName: "myapp-images-v1",
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 200,
        maxAgeSeconds: 30 * 24 * 60 * 60,
        purgeOnQuotaError: true,
      }),
    ],
  }),
);

registerRoute(
  ({ request }) => request.destination === "font",
  new StaleWhileRevalidate({
    cacheName: "myapp-fonts-v1",
    plugins: [new ExpirationPlugin({ maxEntries: 20 })],
  }),
);

// --- Last resort. --------------------------------------------------------------
setCatchHandler(async ({ request }) => {
  if (request.destination === "document") {
    return (await matchPrecache("/offline.html")) ?? Response.error();
  }
  return Response.error();
});

// Navigation preload is NOT enabled: the NavigationRoute above serves the
// precached shell and never fetches, so preload would waste server work.
navigationPreload.disable();
scripts/build-sw.mjs
import { build } from "esbuild";
import { injectManifest } from "workbox-build";

// 1. Bundle the worker. No minification yet: minifiers may rename `self`
//    and break the injection point.
await build({
  entryPoints: ["src/sw.js"],
  bundle: true,
  format: "iife",            // classic script: works everywhere, including Safari
  target: ["es2020"],
  define: { "process.env.NODE_ENV": '"production"' },
  outfile: "build/sw.bundle.js",
  sourcemap: false,
});

// 2. Inject the manifest into the bundle.
const { count, size, warnings } = await injectManifest({
  swSrc: "build/sw.bundle.js",
  swDest: "build/sw.injected.js",
  globDirectory: "dist",
  globPatterns: ["**/*.{html,js,css,woff2,svg,webmanifest}"],
  globIgnores: ["**/*.map", "sw.js"],
  dontCacheBustURLsMatching: /\.[0-9a-f]{8,}\./,
});
if (warnings.length) {
  console.error(warnings.join("\n"));
  process.exit(1);
}

// 3. Minify after injection and write the final file into the deploy directory.
await build({
  entryPoints: ["build/sw.injected.js"],
  minify: true,
  outfile: "dist/sw.js",
  allowOverwrite: true,
});
console.log(`sw.js precaches ${count} files (${(size / 1024).toFixed(1)} KiB)`);

globIgnores excludes sw.js itself: a worker must never precache itself, because the precache would then pin an old copy of the worker script.

Configuration 4: the page side

src/main.js
import { registerServiceWorker } from "./register-sw.js";

registerServiceWorker((onAccept) => {
  const bar = document.createElement("div");
  bar.setAttribute("role", "status");
  bar.className = "update-bar";
  bar.innerHTML = `A new version is available. <button type="button">Reload</button>`;
  bar.querySelector("button").addEventListener("click", () => {
    bar.remove();
    onAccept();
  });
  document.body.append(bar);
});

Serve sw.js with Cache-Control: no-cache (or max-age=0) so update checks aren't slowed by the HTTP cache. Serve hashed assets with a long max-age and immutable. HTTP caching and service workers explains how the two cache layers interact.

Common pitfalls

Symptom Cause Fix
Uncaught ReferenceError: process is not defined in the worker Bundler didn't replace process.env.NODE_ENV Add a define/replace step
Build error "Unable to find a place to inject the manifest" swSrc has no self.__WB_MANIFEST, or swSrc === swDest Reference it exactly once; write to a different file
Build error "…contains only one match…" A comment or second call mentions self.__WB_MANIFEST Remove the extra occurrence
Browser: "Cannot use import statement outside a module" injectManifest output wasn't bundled Bundle before injecting, or use webpack InjectManifest
Warning "…is 3.1 MB, and won't be precached" Default maximumFileSizeToCacheInBytes is 2 MiB Raise it deliberately, or leave large files to runtime caching
Precache install fails with bad-precaching-response A manifest URL returns 4xx/5xx on the server (wrong modifyURLPrefix, base path) Check the deployed URLs; modifyURLPrefix/manifestTransforms
API 404 or 500 pages served offline as if valid NetworkFirst/StaleWhileRevalidate cache opaque responses; custom plugin allowed non-200 Add CacheableResponsePlugin({statuses: [200]})
Cross-origin route never matches RegExp matched mid-URL on a cross-origin request Anchor the pattern at the start of the full URL
Old app runs against new cache after deploy skipWaiting: true in an SPA Use the prompt-to-update pattern
workbox-*.js 404 in production Deploy only uploaded sw.js Upload the chunk, or set inlineWorkboxRuntime: true
ExpirationPlugin throws expire-custom-caches-only Strategy has no cacheName Give the strategy a cache name
Nothing happens offline for some routes Route not matched and no default handler, so the request goes to the network and fails Add a route, a default handler, or a catch handler

The service worker pitfalls page covers the general, non-Workbox anti-patterns.

Debugging Workbox

Development builds (mode: "development", or any bundle where process.env.NODE_ENV !== "production") log a collapsed console group for every routed request. The group lists the route that matched, the strategy's decisions ("Found a cached response in the 'pages' cache", "Timing out the network response at 3 seconds"), and plugin effects such as expiration. Production builds strip all of it. To silence development logs, set self.__WB_DISABLE_DEV_LOGS = true at the top of the worker (generateSW: disableDevLogs: true). Development builds also run argument assertions that throw a WorkboxError with a named code (for example invalid-string, add-to-cache-list-conflicting-entries or no-response), which production builds skip. Advanced Workbox lists the error codes you are most likely to hit, and browser DevTools shows how to inspect Cache Storage and IndexedDB.

Browser support

Workbox itself has no browser-specific code paths beyond feature detection. What works depends on the platform APIs underneath:

Feature Workbox relies on Chrome Edge Firefox Safari (macOS / iOS) Workbox behavior without it
Service workers ✅ 40+ ✅ 17+ ✅ 44+ ✅ 11.1 / 11.3+ Workbox never runs
Navigation preload (workbox-navigation-preload) ✅ 59+ ✅ 18+ ✅ 99+ ✅ 15.4+ enable() is a no-op
Background Sync (workbox-background-sync) ✅ 49+ ✅ 79+ ❌ ❌ Queue replays when the worker next starts up
BroadcastChannel (optional in custom broadcast code) ✅ 54+ ✅ 79+ ✅ 38+ ✅ 15.4+ workbox-broadcast-update uses postMessage() to clients, so it doesn't depend on it
Module service workers (type: "module") ✅ 91+ ✅ 91+ ✅ 147+ ✅ 15+ Use classic bundles; generateSW output is classic anyway

Support data as of September 2026, from @mdn/browser-compat-data 8.1.3. Check MDN's service worker API compatibility and caniuse for live data. The default babelPresetEnvTargets of ["chrome >= 56"] only affects syntax transpilation of the generateSW bundle; it doesn't limit which browsers the worker runs in.

Further reading

On this site

External references