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
generateSWwhen all you need is precaching plus declarative runtime caching. ChooseinjectManifestas soon as you need your own code in the worker, such as push, custom routes, custom plugins or message handlers.injectManifestonly replacesself.__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.RegExproutes 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.
CacheFirststores only200responses (andCacheOnlynever writes at all).NetworkFirstandStaleWhileRevalidatealso store opaque (status: 0) responses unless you add your owncacheWillUpdateplugin. StrategyHandler.fetch()automatically consumesevent.preloadResponsefor navigations and ignoresfetchOptionsfor them. That behavior matters when you enable navigation preload.workbox-windowflags any update found more than 60 seconds afterregister(), or after an earlierupdatefound, asisExternal: 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:
- 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. - 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
QuotaExceededErrorcan 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-cliandworkbox-webpack-plugin7.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
Serwistclass 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¶
Bundled npm imports (recommended)¶
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:
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:
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 servesimportScripts()from the scripts it stored during installation. If the first access to a module happens inside afetchormessagehandler, it throws aNetworkError. Always reference every module at the top level.- Module workers can't use it.
importScripts()throws aTypeErrorin 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 intodist/workbox-v7.4.1/, andworkbox.setConfig({ modulePathPrefix: "/workbox-v7.4.1/" })points the loader at them. AmodulePathCb(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:
importScripts(...)for every entry in theimportScriptsoption.enable()fromworkbox-navigation-preload, ifnavigationPreload: true.setCacheNameDetails({prefix: cacheId}), ifcacheIdis set.self.skipWaiting()unconditionally ifskipWaiting: true. Otherwise it adds amessagelistener that callsself.skipWaiting()when it receives{type: "SKIP_WAITING"}. That listener is the contractworkbox-window'smessageSkipWaiting()relies on.clientsClaim(), ifclientsClaim: true.precacheAndRoute(<manifest>, {directoryIndex, ignoreURLParametersMatching}), if the manifest isn't empty.cleanupOutdatedCaches(), ifcleanupOutdatedCaches: true.registerRoute(new NavigationRoute(createHandlerBoundToURL(navigateFallback), {allowlist, denylist})), ifnavigateFallbackis set.- One
registerRoute()perruntimeCachingentry, in array order. initialize()fromworkbox-google-analytics, ifofflineGoogleAnalyticsis set.self.__WB_DISABLE_DEV_LOGS = true, ifdisableDevLogs: 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-*.jsalongsidesw.js, from the same directory. sw.jsis a classic script. Don't register it with{type: "module"}.- Setting
inlineWorkboxRuntime: trueproduces a single self-contained file (Rollupesformat, 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
swSrccontainsimport {precacheAndRoute} from "workbox-precaching", the output still contains that bare import, and the browser rejects it. Withworkbox-buildorworkbox-cli, you bundle first and inject second (the complete example below shows the order). The webpackInjectManifestplugin is the exception: it compilesswSrcin 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_MANIFESTcounts as a match. Minifiers can also rewriteselfto a local alias. Inject into unminified output, or minify after injecting. swSrcandswDestmust 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 errorsame-src-and-destin that case.
A TypeScript declaration keeps self.__WB_MANIFEST typed:
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:
/**
* @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.
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:
- 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. - Which file types would you like to precache? A checkbox list built from the extensions actually found in that directory. The answer becomes
globPatterns. - Where would you like your service worker file to be saved? Becomes
swDest, default<root>/sw.js. - With
--injectManifestonly: Where's your existing service worker file? BecomesswSrc. - Does your web app manifest include search parameter(s) in the
start_url, other thanutm_orfbclid(like?source=pwa)? If you answer yes, the parameters are added toignoreURLParametersMatching, so the precachedstart_urlstill matches the launch URL. - 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:
/** @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.
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:
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 |
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()):
- Non-HTTP URLs are skipped. If
url.protocoldoesn't start withhttp, the router returnsundefined, and the event gets no response from Workbox. - 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.
- The match return value becomes
params. If the callback returns a non-empty array or object, it is passed to the handler asparams.RegExpRoutepasses the capture groups (result.slice(1)). - No match falls through to the default handler. Without a default handler for that method,
handleRequest()returnsundefinedandevent.respondWith()is never called, so the browser performs the request as if there were no service worker. - A rejected handler promise goes to the catch handlers. The route's own
catchHandlerruns first, then the global one fromsetCatchHandler(). 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: the SPA shell route¶
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:
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¶
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:
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
NetworkFirstandStaleWhileRevalidate. An opaque cross-origin response can hide a404or a500, and it takes up padded quota in Chromium (several megabytes per entry). If you route cross-origin requests to these strategies, addnew CacheableResponsePlugin({statuses: [200]})unless you really want opaque entries. Adding any plugin withcacheWillUpdatereplaces the built-in rule entirely. - The
NetworkFirsttimeout doesn't cancel the fetch. WhennetworkTimeoutSecondselapses, the strategy resolves with the cached response, if one exists. The network request keeps running, and its response still updates the cache (the handlerwaitUntil()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 returnundefinedor aResponse.error(). That throw is what triggershandlerDidErrorplugins and catch handlers. - Default cache names depend on scope. A strategy without
cacheNameusesworkbox-runtime-<registration.scope>. The precache usesworkbox-precache-v2-<scope>, and Google Analytics usesworkbox-googleAnalytics-<scope>. Change theworkboxprefix withsetCacheNameDetails({prefix}), or withcacheIdingenerateSW.cacheIdis useful when several apps sharehttp://localhost:8080in 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: aRequestInitpassed tofetch(). It is ignored for navigation requests (request.mode === "navigate"). Under the Fetch specification, passing any non-emptyinittogether with a navigationRequestdowngrades its mode fromnavigatetosame-origin.matchOptions:CacheQueryOptions(ignoreSearch,ignoreVary,ignoreMethod) used forcaches.match(). In development, Workbox logs a hint to setignoreVary: truewhenever it caches a response carrying aVaryheader.
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:
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 storecache-entries, indexed by timestamp), with one record per cached URL and cache name. cacheDidUpdatewritesDate.now()for the URL and then runs expiration for that cache.cachedResponseWillBeUsedalso writesDate.now()for the URL (insideevent.waitUntil()), kicks off expiration in the background, and returnsnull(treated as a cache miss) if the response'sDateheader is older thanmaxAgeSeconds. Responses without a parseableDateheader are treated as fresh at read time.- Eviction is least-recently-used, not first-in-first-out. Because reads refresh the timestamp,
maxEntrieskeeps the most recently read or written entries, andmaxAgeSecondsin the IndexedDB sweep means "not touched for this long". TheDateheader 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()fromactivateif you need a sweep on every update. - It refuses the default runtime cache. If the strategy has no explicit
cacheName, the plugin throwsexpire-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: trueregisters a callback withregisterQuotaErrorCallback(). When any Workboxcache.put()throwsQuotaExceededError, 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:
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.
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 whenrevisionisnull(hashed file names). A request is looked up by generating candidate URLs: the exact URL; the URL withignoreURLParametersMatchingparameters removed; plusdirectoryIndexif the path ends in/; plus.htmlifcleanURLs; and finally whateverurlManipulationreturns. - 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 usecache: "reload"to bypass the HTTP cache; all entries usecredentials: "same-origin"and carryintegritywhen 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 (
fallbackToNetworkdefaults totrue) and, in development, logs a warning. That happens when the cache was evicted or cleared by the user. - Cleanup of old precaches.
cleanupOutdatedCaches()adds anactivatelistener that deletes every cache whose name contains-precache-and the currentregistration.scope, except the current precache. It exists for precaches left by older, incompatible Workbox versions. It also removes a precache left behind when you changecacheId. It does nothing for your own old runtime caches: delete those yourself inactivate. - 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 with Workbox¶
Navigation preload lets the browser start the navigation request while the service worker boots. Workbox handles the two halves separately:
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 forloadunlessimmediate: true. Registering afterloadkeeps the worker's install-time precache downloads from competing with the page's own resources. Useimmediateonly 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 returnsundefined. Create a newWorkboxinstance 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 inPromise.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:
- This isn't the first
updatefoundthis instance has seen. - The installing worker's script URL differs from the one passed to the constructor.
- 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¶
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.
/** @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 },
},
},
],
};
// 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¶
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.
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();
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¶
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
- Advanced Workbox: plugin lifecycle, custom strategies, recipes, background sync, Serwist migration
- Vite PWA plugin: the most common way Workbox is used today
- Caching strategies: the patterns behind the built-in strategies
- Precaching and runtime caching: how Workbox precaching works internally
- Updating service workers: the lifecycle
workbox-windowwraps - Navigation preload
- Offline UX and fallbacks
- Tooling overview
External references