Advanced Workbox¶
Once the built-in strategies and build options run out, Workbox gives you three extension points: plugins, which hook twelve lifecycle callbacks of every strategy run; custom strategies, which subclass Strategy and use StrategyHandler's plugin-aware helpers; and the lower-level modules (workbox-background-sync, workbox-broadcast-update, workbox-range-requests, workbox-recipes), which are built on those same primitives. This page documents all of them from the Workbox 7.4.1 source, shows how to debug them, and explains how to migrate to the Serwist fork. It assumes you have read Workbox fundamentals.
Key takeaways
- Every strategy run creates a
StrategyHandler, which calls plugin callbacks in array order and gives each plugin a privatestateobject for that request. ReturningnullfromcacheWillUpdateorcachedResponseWillBeUsedmeans "don't cache" or "treat as a miss". fetchDidFailfires only for network errors. HTTP4xx/5xxresponses count as successes everywhere: infetchDidFail, inBackgroundSyncPlugin, and inQueue.replayRequests(). If you want a503treated as a failure, throw fromfetchDidSucceed.- A custom strategy must go through
handler.fetch(),handler.cacheMatch()andhandler.cachePut(). Callingfetch()orcachesdirectly silently bypasses every plugin, navigation preload, andwaitUntil()bookkeeping. RangeRequestsPlugincan only slice a complete200response that is already in the cache. A206from the network can never be stored, becauseCache.put()rejects partial responses. Cache media explicitly, without aRangeheader.BroadcastUpdatePlugincompares onlyContent-Length,ETagandLast-Modifiedby default. Unless at least one of those headers is present on both the old and the new response, it assumes nothing changed and sends no message.- Serwist 9 is ESM-only, has no
generateSW, renames the injection point toself.__SW_MANIFEST, and usesserwist-cache and IndexedDB names. Plan for a one-time re-download of the precache, and drain your Workbox background-sync queues before you switch.
How a strategy run works internally¶
Everything on this page builds on the handful of lines in Strategy.handleAll(). For each request it:
- Creates a
StrategyHandler(strategy, {event, request, params}). The constructor snapshotsstrategy.plugins, creates an emptystateobject per plugin, and callsevent.waitUntil()on an internal deferred promise. From that moment the worker is kept alive until the handler is destroyed. Because that call is synchronous, the handler must be created while the event can still be extended: during dispatch, or while arespondWith()/waitUntil()promise is pending. Otherwise the constructor throwsInvalidStateError. (Callbacks are later iterated from the livestrategy.pluginsarray, so a plugin pushed onto a strategy mid-request runs withstateset toundefined. Add plugins at construction time only.) - Calls
_getResponse(). It runshandlerWillStart, then your_handle(). If_handle()threw, or returned nothing or aResponse.error(), it runshandlerDidErrorplugins until one returns a response. Then it runs everyhandlerWillRespondplugin, each receiving the previous one's response. - Calls
_awaitComplete()in parallel. It waits for the response, runshandlerDidRespond, thendoneWaiting(), which loops until every promise passed tohandler.waitUntil()has settled (including promises added while waiting). Then it runshandlerDidCompletewith any error from those promises, and finallydestroy(), which resolves the deferred promise from step 1.
handle() returns only the response promise; handleAll() returns [responseDone, handlerDone]. Since 7.4.0, doneWaiting() uses Promise.allSettled() and rethrows the first rejection. Earlier versions could produce unhandled rejections when more than one background task failed.
The sequence below shows a CacheFirst cache miss with one plugin that implements every callback:
sequenceDiagram
participant FE as fetch event
participant S as CacheFirst
participant H as StrategyHandler
participant P as Plugin
participant C as Cache Storage
participant N as Network
FE->>S: handleAll({event, request})
S->>H: new StrategyHandler (event.waitUntil(deferred))
S->>P: handlerWillStart
S->>H: cacheMatch(request)
H->>P: cacheKeyWillBeUsed (mode "read")
H->>C: caches.match(key, {cacheName, ...matchOptions})
H->>P: cachedResponseWillBeUsed (cachedResponse undefined)
S->>H: fetchAndCachePut(request)
H->>P: requestWillFetch
H->>N: fetch(request, fetchOptions)
H->>P: fetchDidSucceed
H-->>S: response (cachePut queued via handler.waitUntil)
S->>P: handlerWillRespond
S-->>FE: responseDone resolves (respondWith)
Note over H,C: cachePut starts after setTimeout(0)
H->>P: cacheKeyWillBeUsed (mode "write")
H->>P: cacheWillUpdate
H->>C: cache.put(key, response)
H->>P: cacheDidUpdate
S->>P: handlerDidRespond
S->>H: doneWaiting() then destroy()
S->>P: handlerDidComplete cachePut() begins with await timeout(0), a macrotask yield. This gives the response a head start to the page before the cache write competes for the response body. In practice handlerWillRespond therefore runs before the write-mode cacheKeyWillBeUsed, but plugins must not depend on that ordering.
Plugin lifecycle callbacks: the complete reference¶
A plugin is any object with one or more of these async methods. TypeScript users can type it as WorkboxPlugin from workbox-core/types.js. Every callback receives the triggering event and a state object private to that plugin and that request.
| Callback | Called from | Parameters (besides event, state) | Return value | Chain semantics |
|---|---|---|---|---|
handlerWillStart | _getResponse() start | request | ignored | all run |
cacheKeyWillBeUsed | getCacheKey() for reads and writes | request, mode: "read" \| "write", params | Request or URL string used as the cache key | each receives the previous result; memoized per url \| mode per handler |
cachedResponseWillBeUsed | cacheMatch() | cacheName, request (the effective key), cachedResponse?, matchOptions? | Response to use, or null/undefined for a miss | each receives the previous result |
requestWillFetch | fetch(), before the network | request (a clone) | Request to send | each receives the previous result; a throw becomes plugin-error-request-will-fetch |
fetchDidSucceed | fetch(), after the network resolves | request, response | Response (required) | each receives the previous result; a throw is handled like a network error |
fetchDidFail | fetch(), when it rejects | error, originalRequest, request | ignored | all run, then the error is rethrown |
cacheWillUpdate | cachePut() via _ensureResponseSafeToCache() | request (the original request), response | Response to store, or null to skip | stops at the first null |
cacheDidUpdate | cachePut(), after a successful cache.put() | cacheName, request (the key), oldResponse?, newResponse | ignored | all run |
handlerDidError | _getResponse() when _handle() failed | request, error | fallback Response or undefined | first non-empty response wins |
handlerWillRespond | _getResponse() end | request, response | Response (required) | each receives the previous result |
handlerDidRespond | _awaitComplete() | request, response? | ignored | all run |
handlerDidComplete | _awaitComplete(), after all waitUntil work | request, response?, error? | ignored | all run |
Handler-level callbacks¶
handlerWillStart, handlerWillRespond, handlerDidRespond, handlerDidComplete and handlerDidError bracket the whole strategy run. They are the right place for metrics and fallbacks:
handlerDidErrorfires only when the strategy produced no usable response. It doesn't fire for a404or a500from the network. Those are valid responses. It is whatPrecacheFallbackPluginuses, and it runs before any route or global catch handler. If a plugin supplies a response, the catch handlers never see the error.handlerWillRespondis the last chance to transform the response the page receives, for example to add a header. You can't mutate headers on aResponsereturned byfetch()(its headers are immutable), so construct a new one. Leave opaque responses (response.type === "opaque") alone. Re-wrapping them yields an empty body, because their contents are hidden from the worker.handlerDidCompleteis the only callback that sees errors from background work: a failed cache write, a quota error, or a rejectedhandler.waitUntil()promise. Log them here.
Cache-level callbacks¶
cacheKeyWillBeUsedruns for every cache read and write, and receivesmode. Use it to normalize keys: strip tracking parameters, remove session tokens, or map several URLs to one entry. The result is memoized per URL and mode for the life of the handler, so it runs once per read and once per write.workbox-precachinguses this hook to map a request URL to its?__WB_REVISION__=key.cachedResponseWillBeUsedruns aftercaches.match(), even on a miss (cachedResponseisundefined). Returningnullturns a hit into a miss.ExpirationPluginreturnsnullfor responses whoseDateheader is older thanmaxAgeSeconds.RangeRequestsPluginreturns a sliced206.cacheWillUpdatedecides cacheability. When a strategy has no plugin with this callback,StrategyHandlercaches onlystatus === 200.NetworkFirstandStaleWhileRevalidateinsert a built-in plugin that also allowsstatus === 0(opaque), but only if you didn't provide one of your own. Returningnullfrom any plugin stops the chain, andcachePut()resolvesfalse.cacheDidUpdategetsoldResponseonly if at least one plugin implements the callback. Only then doescachePut()look up the previous entry, matching while ignoring the__WB_REVISION__parameter.BroadcastUpdatePluginandExpirationPluginboth use it.
Network-level callbacks¶
requestWillFetchcan rewrite the outgoing request: add anAuthorizationheader from IndexedDB, switch to a CDN host, or add a cache-busting parameter. It receives a clone. Return a newRequest, don't mutate the clone. It is not called when a navigation is satisfied byevent.preloadResponse, becauseStrategyHandler.fetch()returns the preload response before running plugins.fetchDidSucceedreceives every response thatfetch()resolved with, including404and500. Throwing here makes the request behave as if the network failed. The throw happens insideStrategyHandler.fetch()'stryblock, sofetchDidFailplugins run next. The strategy then falls back (for example,NetworkFirstserves the cache), andBackgroundSyncPluginqueues the request.fetchDidFailreceivesoriginalRequest, the request before anyrequestWillFetchrewrite, andrequest, the rewritten one.StrategyHandleronly clones the original request when some plugin implementsfetchDidFail, so the callback costs nothing when unused.
Per-request plugin state¶
state is a plain object created per plugin per StrategyHandler, so per request. It is the same object in every callback of that plugin during that run, and it is not shared between plugins. Use it to carry data between callbacks without globals, which would be wrong because many requests run concurrently in one worker.
Production-grade plugins¶
The plugins below were bundled and syntax-checked against workbox-* 7.4.1.
Cache-status header and timing plugin¶
/**
* Adds `X-SW-Cache: hit|miss|fallback` and a Server-Timing entry to every
* response a strategy returns, and logs slow requests. Uses per-request state.
* @param {{slowMs?: number}} [options]
* @returns {import('workbox-core/types.js').WorkboxPlugin}
*/
export function cacheStatusPlugin({ slowMs = 1500 } = {}) {
return {
async handlerWillStart({ state }) {
state.start = performance.now();
state.status = "miss";
},
async cachedResponseWillBeUsed({ cachedResponse, state }) {
if (cachedResponse) state.status = "hit";
return cachedResponse;
},
async handlerDidError({ state }) {
state.status = "fallback";
return undefined; // let other plugins or catch handlers supply the fallback
},
async handlerWillRespond({ response, state }) {
// Opaque and error responses cannot be re-wrapped without losing them.
if (response.type === "opaque" || response.type === "opaqueredirect") return response;
const duration = performance.now() - state.start;
const headers = new Headers(response.headers);
headers.set("X-SW-Cache", state.status);
headers.append("Server-Timing", `sw;desc="${state.status}";dur=${duration.toFixed(1)}`);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
},
async handlerDidComplete({ request, error, state }) {
const total = performance.now() - state.start;
if (error) {
console.warn("[sw] background work failed for", request.url, error);
} else if (total > slowMs) {
console.info(`[sw] ${request.url} took ${total.toFixed(0)} ms (${state.status})`);
}
},
};
}
Re-wrapping keeps the body as a stream, so the page still receives it progressively. The X-SW-Cache header lets you check the cache status in DevTools and in end-to-end tests. See measuring performance for how to collect it in the field. If a navigation response is re-wrapped, response.url becomes empty. That's harmless for navigations, but don't re-wrap redirected responses you intend to cache.
Cache key normalization plugin¶
const DROP_PARAMS = [/^utm_/, /^fbclid$/, /^gclid$/, /^_$/, /^token$/];
/**
* Removes tracking, cache-busting and credential query parameters from cache
* keys, and sorts the rest so ?a=1&b=2 and ?b=2&a=1 share one entry.
* The network request is not modified.
* @returns {import('workbox-core/types.js').WorkboxPlugin}
*/
export function normalizeCacheKeyPlugin() {
return {
async cacheKeyWillBeUsed({ request }) {
const url = new URL(request.url);
for (const name of [...url.searchParams.keys()]) {
if (DROP_PARAMS.some((re) => re.test(name))) url.searchParams.delete(name);
}
url.searchParams.sort();
url.hash = "";
// Returning a string is allowed; Workbox converts it with new Request().
return url.href;
},
};
}
Because the key is a new Request built from a URL, it carries no headers. That's usually what you want, but it means matchOptions.ignoreVary has nothing to ignore. Don't combine key normalization with responses that genuinely vary by request header.
Treat server errors as network failures¶
/**
* Makes 5xx (and optionally 429) responses behave like network errors:
* NetworkFirst falls back to the cache, BackgroundSyncPlugin queues the request.
* Only use with idempotent requests or requests carrying an idempotency key:
* the server did receive the original request.
* @param {{retryOn429?: boolean}} [options]
* @returns {import('workbox-core/types.js').WorkboxPlugin}
*/
export function serverErrorsAsFailuresPlugin({ retryOn429 = true } = {}) {
return {
async fetchDidSucceed({ response }) {
if (response.status >= 500 || (retryOn429 && response.status === 429)) {
throw new Error(`HTTP ${response.status} treated as failure`);
}
return response;
},
};
}
Freshness plugin with a stored timestamp¶
ExpirationPlugin measures age from the Date header, and some servers or CDNs rewrite that header. This plugin stamps the time Workbox stored the response in a private header, then refuses entries older than a limit:
const HEADER = "x-sw-stored-at";
/**
* @param {{maxAgeSeconds: number}} options
* @returns {import('workbox-core/types.js').WorkboxPlugin}
*/
export function maxStalePlugin({ maxAgeSeconds }) {
return {
async cacheWillUpdate({ response }) {
if (response.status !== 200) return null; // keep default cacheability
const headers = new Headers(response.headers);
headers.set(HEADER, String(Date.now()));
// Buffer the body: it is being written to the cache, not streamed to the page.
const body = await response.clone().arrayBuffer();
return new Response(body, { status: 200, statusText: response.statusText, headers });
},
async cachedResponseWillBeUsed({ cachedResponse }) {
if (!cachedResponse) return cachedResponse;
const storedAt = Number(cachedResponse.headers.get(HEADER));
if (!storedAt) return cachedResponse; // entry written before this plugin shipped
const ageSeconds = (Date.now() - storedAt) / 1000;
return ageSeconds <= maxAgeSeconds ? cachedResponse : null;
},
};
}
cacheWillUpdate receives the response after fetchAndCachePut() cloned it for the page. Reading its body here doesn't affect what the page receives. Because this plugin defines cacheWillUpdate, it replaces the strategy's default cacheability check, which is why it rejects non-200 responses itself.
Custom strategies: extending Strategy¶
Subclass Strategy and implement one method:
protected abstract _handle(request: Request, handler: StrategyHandler): Promise<Response | undefined>;
The constructor options (cacheName, plugins, fetchOptions, matchOptions) are handled by the base class. The StrategyHandler API is all you need inside _handle():
| Method | What it does, including plugin calls |
|---|---|
handler.fetch(input) | Uses event.preloadResponse for navigations, else runs requestWillFetch, fetch() with the strategy's fetchOptions (not for navigations), fetchDidSucceed, or fetchDidFail + rethrow |
handler.cacheMatch(key) | cacheKeyWillBeUsed("read"), caches.match() in cacheName, cachedResponseWillBeUsed |
handler.cachePut(key, response) | cacheKeyWillBeUsed("write"), cacheWillUpdate, cache.put(), quota callbacks on QuotaExceededError, cacheDidUpdate; resolves true if stored |
handler.fetchAndCachePut(input) | fetch(), then waitUntil(cachePut(clone)); returns the network response |
handler.waitUntil(promise) | Adds to the promises doneWaiting() awaits before handlerDidComplete |
handler.getCacheKey(request, mode) | Just the cacheKeyWillBeUsed chain |
handler.runCallbacks(name, param) / iterateCallbacks(name) | Invoke plugin callbacks yourself, e.g. a custom lifecycle hook |
handler.hasCallback(name) | Whether any plugin implements a callback |
handler.event, handler.request, handler.url, handler.params | Context from the router |
Rules for correct custom strategies:
- Never call
fetch()orcachesdirectly. Doing so bypasses plugins, navigation preload and lifetime extension. If you need a different cache, create a second strategy instance and call itshandle(). - Throw, or return
undefined, when you have nothing. The base class convertsundefinedandResponse.error()intoWorkboxError('no-response'), which triggershandlerDidErrorand the catch handlers. Don't return a synthetic503unless the page should really see one. - Register background work with
handler.waitUntil(), not withevent.waitUntil()directly. Onlyhandler.waitUntil()holdshandlerDidCompleteback until the work finishes. - Keep
_handle()free of per-instance mutable state. One instance serves every matching request concurrently.
Example: cache and network race¶
Useful on devices with slow storage, or when the cache and the network perform about equally: answer with whichever resolves first, and keep the cache fresh.
import { Strategy } from "workbox-strategies";
/**
* Resolves with the first usable response from the cache or the network.
* The network response always updates the cache in the background.
*/
export class CacheNetworkRace extends Strategy {
async _handle(request, handler) {
const fromNetwork = handler.fetchAndCachePut(request); // cachePut is waitUntil()'d
const fromCache = handler.cacheMatch(request);
return new Promise((resolve, reject) => {
let settled = 0;
let resolved = false;
let firstError;
const settle = (response, error) => {
settled += 1;
if (response && !resolved) {
resolved = true;
resolve(response);
return;
}
firstError ??= error;
if (settled === 2 && !resolved) {
// Both failed or the cache missed and the network failed.
reject(firstError ?? new Error("no-response"));
}
};
fromCache.then((r) => settle(r), (e) => settle(undefined, e));
fromNetwork.then((r) => settle(r), (e) => settle(undefined, e));
});
}
}
Example: stale-while-revalidate with a freshness window¶
StaleWhileRevalidate always serves the cache, however old the entry is. This variant serves cached responses younger than maxAgeSeconds immediately and revalidates them in the background. For older entries it tries the network first, with a timeout, and falls back to the stale entry when the network fails, times out or answers with a 5xx.
import { Strategy } from "workbox-strategies";
const STORED_AT = "x-sw-stored-at";
export class FreshWhileRevalidate extends Strategy {
/**
* @param {import('workbox-strategies').StrategyOptions & {maxAgeSeconds?: number, networkTimeoutSeconds?: number}} options
*/
constructor(options = {}) {
super(options);
this.maxAgeMs = (options.maxAgeSeconds ?? 60) * 1000;
this.timeoutMs = (options.networkTimeoutSeconds ?? 4) * 1000;
// Stamp every stored response so age does not depend on the Date header.
this.plugins.push({
cacheWillUpdate: async ({ response }) => {
if (response.status !== 200) return null;
const headers = new Headers(response.headers);
headers.set(STORED_AT, String(Date.now()));
return new Response(await response.clone().blob(), {
status: 200,
statusText: response.statusText,
headers,
});
},
});
}
async _handle(request, handler) {
const cached = await handler.cacheMatch(request);
const storedAt = Number(cached?.headers.get(STORED_AT) ?? 0);
const isFresh = cached && Date.now() - storedAt < this.maxAgeMs;
if (isFresh) {
// Serve immediately, refresh in the background (errors are ignored).
handler.waitUntil(handler.fetchAndCachePut(request).catch(() => undefined));
return cached;
}
const network = handler.fetchAndCachePut(request);
if (!cached) return network; // nothing to fall back to: surface network errors
let timer;
const timeout = new Promise((resolve) => {
timer = setTimeout(() => resolve(undefined), this.timeoutMs);
});
try {
const winner = await Promise.race([network.catch(() => undefined), timeout]);
// A 5xx is worse than a stale copy; 2xx-4xx responses are authoritative.
return winner && winner.status < 500 ? winner : cached;
} finally {
clearTimeout(timer);
// Let a slow network response still update the cache.
handler.waitUntil(network.catch(() => undefined));
}
}
}
The class adds its stamping plugin in the constructor, after super() has stored your plugins, so the base cacheability rule (200 only) is replaced by an equivalent one. Use it like any built-in strategy:
import { registerRoute } from "workbox-routing";
import { ExpirationPlugin } from "workbox-expiration";
import { FreshWhileRevalidate } from "./strategies/fresh-while-revalidate.js";
import { cacheStatusPlugin } from "./plugins/cache-status.js";
registerRoute(
({ url }) => url.pathname.startsWith("/api/catalog/"),
new FreshWhileRevalidate({
cacheName: "catalog-v1",
maxAgeSeconds: 300,
networkTimeoutSeconds: 3,
plugins: [cacheStatusPlugin(), new ExpirationPlugin({ maxEntries: 200 })],
}),
);
workbox-recipes¶
workbox-recipes packages the most common route setups as functions. Each one calls registerRoute() on the default router (or setCatchHandler()), so the order in which you call them is the route order. The defaults below come from the 7.4.1 source:
| Recipe | Matches by default | Strategy | Cache name | Built-in plugins and defaults |
|---|---|---|---|---|
pageCache(options?) | request.mode === "navigate" | NetworkFirst, networkTimeoutSeconds: 3 | pages | CacheableResponsePlugin({statuses: [0, 200]}) |
staticResourceCache(options?) | destination is style, script or worker | StaleWhileRevalidate | static-resources | CacheableResponsePlugin({statuses: [0, 200]}) |
imageCache(options?) | destination === "image" | CacheFirst | images | CacheableResponsePlugin([0, 200]), ExpirationPlugin({maxEntries: 60, maxAgeSeconds: 30 days}) |
googleFontsCache(options?) | origins fonts.googleapis.com / fonts.gstatic.com | StaleWhileRevalidate for stylesheets, CacheFirst for font files | google-fonts-stylesheets, google-fonts-webfonts (prefix configurable via cachePrefix) | Font files: [0, 200], maxEntries: 30, maxAgeSeconds: 1 year |
offlineFallback(options?) | Global catch handler | n/a | workbox-offline-fallbacks | pageFallback: "offline.html", optional imageFallback, fontFallback |
warmStrategyCache({urls, strategy}) | n/a | Uses the given strategy during install | the strategy's | Awaits handleAll()[1] for every URL |
Options common to the route recipes: cacheName, matchCallback, plugins (your plugins run before the built-in ones, which are pushed onto the same array), and warmCache: string[], which calls warmStrategyCache() for you. pageCache also accepts networkTimeoutSeconds. imageCache accepts maxEntries and maxAgeSeconds.
Details that matter:
offlineFallback()replaces the global catch handler withsetCatchHandler(). If you callsetCatchHandler()yourself afterwards, you replace the recipe, and vice versa. Its handler checksmatchPrecache(fallback)first and then its ownworkbox-offline-fallbackscache, which it fills in its owninstalllistener withcache.addAll(). If any fallback URL fails to download, the install fails.offlineFallback()'s default"offline.html"is relative, resolved against the worker's URL. Pass an absolute path such as/offline.htmlif the worker isn't at the root.warmStrategyCache()runs duringinstall. Every warm URL is fetched through the strategy, and therefore through its plugins, before the worker can activate. A cache write that fails (for exampleQuotaExceededError) makes the install fail. A network failure for one URL does not, becausehandleAll()'s second promise only rejects onwaitUntilerrors.- The recipes' opaque-friendly defaults (
statuses: [0, 200]) mean third-party scripts and images without CORS are cached. Opaque responses carry the quota padding described in storage quotas.
A worker built only from recipes:
import { precacheAndRoute, cleanupOutdatedCaches } from "workbox-precaching";
import {
pageCache,
imageCache,
staticResourceCache,
googleFontsCache,
offlineFallback,
} from "workbox-recipes";
precacheAndRoute(self.__WB_MANIFEST); // must include /offline.html
cleanupOutdatedCaches();
self.addEventListener("message", (event) => {
if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
});
googleFontsCache();
staticResourceCache();
imageCache({ maxEntries: 120 });
pageCache({ networkTimeoutSeconds: 4, warmCache: ["/", "/pricing"] });
offlineFallback({ pageFallback: "/offline.html", imageFallback: "/img/offline.svg" });
Range requests: serving cached audio and video¶
<video> and <audio> elements, and some PDF viewers, request media with a Range header such as Range: bytes=0- or Range: bytes=1048576-2097151. Three facts constrain how you can serve those requests from a cache:
Cache.put()rejects206 Partial Contentresponses (the Cache API specification requires it), and Workbox's default cacheability rule only stores200. A network response to aRangerequest therefore can't be cached. You have to store the full file some other way.- The cache holds a full
200, but the element expects a206with aContent-Rangeheader.RangeRequestsPluginbridges the two incachedResponseWillBeUsed. When the request has aRangeheader and a cached response exists, it callscreatePartialResponse(). - Cross-origin media must be CORS-enabled. For an opaque response, the worker can't read the body, so slicing fails and the plugin returns
416. Addcrossorigin="anonymous"to the element, and have the media server sendAccess-Control-Allow-Origin.
createPartialResponse() behaves as follows (from workbox-range-requests 7.4.1):
- If the cached response is already a
206, it is returned unchanged. - The header must start with
bytes=(otherwiseunit-must-be-bytes) and contain a single range. Multi-range requests (bytes=0-99,200-299) are rejected withsingle-range-only. bytes=500-,bytes=-500(suffix length) andbytes=0-999are supported. The end is inclusive, as in HTTP.- It reads the entire cached body into a
Blob, slices it, and returns a206withContent-Range: bytes start-end/totaland a matchingContent-Length. All other headers are copied from the cached response. - On any parsing or boundary error, it returns an empty
416 Range Not Satisfiablerather than throwing.
Because the whole blob is loaded for every range request, a 1 GB video causes 1 GB blob reads on each seek. Browsers back blobs with disk, but memory pressure and latency still grow with file size. Keep cached media to a size you would be comfortable reading in full.
A route that serves saved media from the cache has to decide synchronously whether a URL was saved, because a route's match callback must return a value, not a promise. A promise is truthy, so an async matcher would match every request. The worker below keeps an in-memory index of saved URLs, rebuilt from the cache when the worker starts and updated by the "save for offline" message handler:
import { registerRoute } from "workbox-routing";
import { CacheOnly } from "workbox-strategies";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
import { RangeRequestsPlugin } from "workbox-range-requests";
const MEDIA_CACHE = "media-v1";
/** URLs saved for offline playback; rebuilt from the cache on every worker start. */
const savedMedia = new Set();
const ready = caches
.open(MEDIA_CACHE)
.then((cache) => cache.keys())
.then((keys) => keys.forEach((r) => savedMedia.add(r.url)));
registerRoute(
({ url, request }) =>
(request.destination === "video" || request.destination === "audio") &&
savedMedia.has(url.href),
new CacheOnly({
cacheName: MEDIA_CACHE,
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new RangeRequestsPlugin(),
],
}),
);
// The page posts {type: "SAVE_MEDIA", url} from a "Download for offline" button.
self.addEventListener("message", (event) => {
if (event.data?.type !== "SAVE_MEDIA") return;
const url = new URL(event.data.url, self.location.href).href;
event.waitUntil(
(async () => {
await ready;
const cache = await caches.open(MEDIA_CACHE);
// A plain fetch without a Range header returns the full 200 response.
const response = await fetch(url, { mode: "cors", credentials: "omit" });
if (!response.ok || response.status !== 200) {
throw new Error(`Cannot save ${url}: HTTP ${response.status}`);
}
await cache.put(url, response);
savedMedia.add(url);
event.source?.postMessage({ type: "MEDIA_SAVED", url });
})(),
);
});
The set is rebuilt asynchronously when the worker starts, so a request that arrives in the first few milliseconds after start-up may miss it and go to the network, which is harmless. If a precached media file is served by the precache route, add the plugin there instead with addPlugins([new RangeRequestsPlugin()]) from workbox-precaching.
Broadcast update: telling pages that cached data changed¶
StaleWhileRevalidate serves a cached response and then fetches a fresh one. BroadcastUpdatePlugin tells open pages when that fresh response differs, so the UI can re-render or show "new data available".
How BroadcastCacheUpdate.notifyIfUpdated() decides and delivers, from the 7.4.1 source:
- It does nothing unless
cacheDidUpdatesupplied anoldResponse. The first write of a URL never notifies. - It compares only the headers in
headersToCheck, by default["content-length", "etag", "last-modified"]. If none of them is present on both responses, the responses are considered identical (a development build logs a warning), and no message is sent. Make sure your API sends anETagorLast-Modified. - For a navigation request, it first waits for the new page's window client to exist. It polls
clients.matchAll()every 100 ms for up to 2 seconds forevent.resultingClientId. If the client doesn't appear, or the browser is Safari, it waits a fixed 3.5 seconds instead, so that the new page has time to add itsmessagelistener. - It posts
{type: "CACHE_UPDATED", meta: "workbox-broadcast-update", payload}withpostMessage()to every window client (notifyAllClients: true, the default). WithnotifyAllClients: false, it posts only toevent.clientId, the page that made the request. The default payload is{cacheName, updatedURL}. Override it withgeneratePayload(options).
The plugin calls notifyIfUpdated() without awaiting it or extending the event's lifetime. If the browser stops the worker during a navigation's 3.5-second wait, the message is lost. Treat the message as a hint, not a guarantee.
import { registerRoute } from "workbox-routing";
import { StaleWhileRevalidate } from "workbox-strategies";
import { BroadcastUpdatePlugin } from "workbox-broadcast-update";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
registerRoute(
({ url }) => url.pathname.startsWith("/api/articles"),
new StaleWhileRevalidate({
cacheName: "articles",
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new BroadcastUpdatePlugin({
headersToCheck: ["etag", "content-length"],
// updatedURL is what the page needs for cache.match(); path lets the
// UI decide what to re-render without parsing URLs.
generatePayload: ({ cacheName, request }) => ({
cacheName,
updatedURL: request.url,
path: new URL(request.url).pathname,
}),
}),
],
}),
);
/**
* Listens for Workbox broadcast-update messages and re-reads the updated
* response from the cache (no second network request).
* @param {(path: string, data: unknown) => void} onUpdate
*/
export function listenForCacheUpdates(onUpdate) {
if (!("serviceWorker" in navigator)) return;
navigator.serviceWorker.addEventListener("message", async (event) => {
const { type, meta, payload } = event.data ?? {};
if (type !== "CACHE_UPDATED" || meta !== "workbox-broadcast-update") return;
try {
const cache = await caches.open(payload.cacheName);
const response = await cache.match(payload.updatedURL);
if (response) onUpdate(payload.path, await response.json());
} catch (error) {
console.warn("Could not read updated cache entry", error);
}
});
// Messages sent before this point are queued until the page starts the
// client message queue; startMessages() does so explicitly.
navigator.serviceWorker.startMessages();
}
With workbox-window, use wb.addEventListener("message", ...) instead. It only surfaces messages from workers the instance owns. The messaging page explains the client message queue that startMessages() controls.
Background sync with the Queue class¶
workbox-background-sync stores failed requests in IndexedDB and replays them later. It uses the Background Sync API where it exists (Chromium) and falls back to replaying on worker start-up elsewhere.
What a Queue does, precisely¶
new Queue(name: string, {
onSync?: ({queue}) => void | Promise<void>, // default: queue.replayRequests()
maxRetentionTime?: number, // minutes; default 60 * 24 * 7 (7 days)
forceSyncFallback?: boolean, // default false
})
queue.pushRequest({request, metadata?, timestamp?}): Promise<void>
queue.unshiftRequest(entry): Promise<void>
queue.shiftRequest(): Promise<QueueEntry | undefined>
queue.popRequest(): Promise<QueueEntry | undefined>
queue.getAll(): Promise<QueueEntry[]> // also deletes expired entries
queue.size(): Promise<number> // includes expired entries
queue.replayRequests(): Promise<void>
queue.registerSync(): Promise<void>
- Names must be unique per worker. Constructing a second
Queuewith the same name in one worker global throwsduplicate-queue-name. Create queues at the top level, never inside afetchhandler. - Storage: IndexedDB database
workbox-background-sync(version 3), object storerequests, indexqueueName. Each entry stores a serializedStorableRequest: the URL, headers,method,referrer,referrerPolicy,mode,credentials,cache,redirect,integrity,keepalive, and the body as anArrayBuffer. That means request bodies are buffered in full, and streaming uploads can't be queued. Files uploaded withFormDataare stored as bytes. - Sync tag:
workbox-background-sync:<name>.pushRequest()callsregistration.sync.register()with this tag, unless a sync is already running for the queue. In that case the queue re-registers after the current sync finishes. - Where
SyncManageris missing (Firefox, Safari), or withforceSyncFallback: true, the constructor callsonSyncimmediately. So the replay attempt happens each time the worker starts and evaluates the queue's constructor, typically on the next navigation or fetch after the worker was stopped. There is no retry timer: if the user never returns, nothing is sent. - Expiry: entries older than
maxRetentionTimeare silently discarded when they are read (shiftRequest,popRequest,getAll). They are never replayed.
What counts as a failure¶
replayRequests() loops over shiftRequest() and calls fetch(entry.request.clone()). Only a rejected fetch() is a failure. The entry is then put back at the front with unshiftRequest(), and the method throws queue-replay-failed. That rejects the sync event's waitUntil(), and Chromium schedules a retry. A 400, 409 or 500 resolves the fetch(), so the entry is dropped as if it succeeded.
Similarly, BackgroundSyncPlugin only implements fetchDidFail, so only network errors are queued. Throwing from a fetchDidSucceed plugin (see Treat server errors as network failures) extends queueing to 5xx. A custom onSync handler extends replay semantics.
BackgroundSyncPlugin or Queue?¶
BackgroundSyncPlugin(name, options) is a thin wrapper: its constructor creates new Queue(name, options), and its only callback, fetchDidFail({request}), calls queue.pushRequest({request}). Attach it to a NetworkOnly strategy on a POST route when the default behavior is enough:
import { registerRoute } from "workbox-routing";
import { NetworkOnly } from "workbox-strategies";
import { BackgroundSyncPlugin } from "workbox-background-sync";
registerRoute(
({ url }) => url.pathname === "/api/events",
new NetworkOnly({
plugins: [new BackgroundSyncPlugin("analytics-events", { maxRetentionTime: 24 * 60 })],
}),
"POST",
);
The plugin still lets the original request fail: the strategy throws no-response, the page's fetch() rejects, and the page can't tell "queued" from "lost". Use the Queue class directly, as in the outbox below, when you need any of the following:
- a synthetic
202 Acceptedfor the page instead of a network error; - request metadata (
pushRequest({request, metadata})), such as a client-generated ID the UI can reconcile later; - a replay policy that treats some HTTP statuses as retryable;
- a notification to open pages when a queued item is finally sent or rejected.
Replaying sooner where the Background Sync API is missing¶
Without SyncManager (Firefox and Safari as of September 2026), the only automatic replay is the one the Queue constructor starts when the worker evaluates. A worker that stays alive, or that restarts only when the user navigates, can sit on a full queue while the device is back online. Give the page a way to ask for a replay:
import { Queue } from "workbox-background-sync";
const outboxQueue = new Queue("outbox-fallback");
self.addEventListener("message", (event) => {
if (event.data?.type !== "REPLAY_OUTBOX") return;
// replayRequests() rejects with queue-replay-failed if still offline;
// swallow it here, the entry is already back at the head of the queue.
event.waitUntil(outboxQueue.replayRequests().catch(() => undefined));
});
// Ask the worker to replay whenever the browser reports connectivity again,
// and once at startup for entries left over from an earlier session.
async function requestReplay() {
const registration = await navigator.serviceWorker.ready;
registration.active?.postMessage({ type: "REPLAY_OUTBOX" });
}
window.addEventListener("online", requestReplay);
requestReplay();
In Chromium this is redundant but harmless: replayRequests() and the sync event both drain the same IndexedDB entries, and shiftRequest() removes each entry before it is sent, so an entry is not sent twice by concurrent replays. It can, however, be sent again by the later replay if the first attempt reached the server but the response was lost, which is another reason for idempotency keys. The online event only means the device has a network interface, not that your server is reachable, so treat it as a hint.
A production outbox with idempotency and error classification¶
import { Queue } from "workbox-background-sync";
import { registerRoute } from "workbox-routing";
import { NetworkOnly } from "workbox-strategies";
const RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504]);
const commentsNetwork = new NetworkOnly({ networkTimeoutSeconds: 10 });
const outbox = new Queue("outbox", {
maxRetentionTime: 3 * 24 * 60, // 3 days, in minutes
async onSync({ queue }) {
let entry;
while ((entry = await queue.shiftRequest())) {
let response;
try {
response = await fetch(entry.request.clone());
} catch (networkError) {
await queue.unshiftRequest(entry); // keep order: put it back in front
throw networkError; // reject the sync event -> browser retries later
}
if (RETRYABLE.has(response.status)) {
await queue.unshiftRequest(entry);
throw new Error(`Retryable HTTP ${response.status}`);
}
// Success or a permanent client error: either way the entry is done.
await notifyClients({
type: response.ok ? "OUTBOX_SENT" : "OUTBOX_REJECTED",
id: entry.metadata?.id,
status: response.status,
});
}
},
});
async function notifyClients(message) {
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
for (const client of windows) client.postMessage(message);
}
// Queue failed POSTs directly from a route handler so the page gets a 202
// instead of a network error while offline.
registerRoute(
({ url, request }) => url.pathname === "/api/comments" && request.method === "POST",
async ({ request, event }) => {
const idempotencyKey = request.headers.get("Idempotency-Key") ?? crypto.randomUUID();
// set(), not append(): a page-supplied key must not become "k1, k1".
const headers = new Headers(request.headers);
headers.set("Idempotency-Key", idempotencyKey);
const withKey = new Request(request, { headers });
try {
return await commentsNetwork.handle({
event,
request: withKey.clone(),
});
} catch {
await outbox.pushRequest({ request: withKey, metadata: { id: idempotencyKey } });
return new Response(JSON.stringify({ queued: true, id: idempotencyKey }), {
status: 202,
headers: { "Content-Type": "application/json" },
});
}
},
"POST",
);
The Idempotency-Key header matters because a request can reach the server and still fail on the client, for example when the connection drops before the response arrives. A replay then sends it a second time. With the key, the server can deduplicate. The offline-first architecture page covers conflict handling for queued writes.
Testing a queue¶
In Chrome DevTools, open Application > Service workers, enter workbox-background-sync:outbox in the Sync field and click the button. That fires a sync event with the tag immediately. Application > Background services > Background sync can record sync registrations and dispatches. The queued entries themselves are in Application > IndexedDB > workbox-background-sync > requests. See browser DevTools for the full walkthrough.
Debugging and logging¶
Development vs production builds¶
Every Workbox module wraps its assertions and logs in if (process.env.NODE_ENV !== "production"). Your build decides which one you get:
| Development build | Production build | |
|---|---|---|
| Console output | Colored workbox prefix; one collapsed group per routed request; install/activate summaries of precached and deleted URLs | None |
| Argument assertions | Throw WorkboxError with a descriptive message | Skipped |
WorkboxError.message | Human-readable, e.g. "The strategy could not generate a response for '…'." | Code plus JSON details, e.g. no-response :: [{"url":"https://…"}] |
| Size of the Config 3 worker from Workbox fundamentals, esbuild 0.28.2 | about 165 KB unminified (32 KB gzipped) | about 30 KB minified (9.7 KB gzipped) |
The logger checks self.__WB_DISABLE_DEV_LOGS on every call, so you can toggle it at runtime from the DevTools console of the worker (self.__WB_DISABLE_DEV_LOGS = true). In Safari, grouped logs use a plain console.groupCollapsed() without the colored prefix.
Error codes you will actually meet¶
| Code | Meaning | Typical cause |
|---|---|---|
no-response | A strategy ended with no response | Offline with an empty cache; CacheOnly miss; no handlerDidError/catch handler |
bad-precaching-response | A precache URL returned 4xx/5xx during install | Wrong base path, deleted file, auth-protected URL in the manifest |
non-precached-url | createHandlerBoundToURL() got a URL not in the manifest | navigateFallback not matched by globPatterns |
add-to-cache-list-conflicting-entries | Same URL, two revisions | Overlapping globs or additionalManifestEntries |
invalid-string (development only) | registerRoute("...") string not starting with / or http | Relative path or Express-style pattern |
duplicate-queue-name | Two Queues with one name | Queue created inside an event handler |
queue-replay-failed | A replay hit a network error | Still offline; retried by the browser |
expire-custom-caches-only | ExpirationPlugin on the default runtime cache | Strategy without cacheName |
max-entries-or-age-required (development only) | ExpirationPlugin({}) | Missing both limits; production builds accept it and never expire |
attempt-to-cache-non-get-request (development only) | cachePut() with a POST | Route with method: "POST" using a caching strategy; production builds get the Cache API's own TypeError from cache.put() |
plugin-error-request-will-fetch | A requestWillFetch callback threw | Bug in a plugin |
single-range-only, unit-must-be-bytes | Unsupported Range header | Multi-range request to RangeRequestsPlugin (the plugin then returns 416) |
Debugging techniques for Workbox routes and strategies¶
- Inspect the per-request log group. In a development build, each routed request logs "Router is responding to: /path", then the matched route, the strategy's decisions and any plugin messages. Unrouted requests produce no log. That absence tells you the router didn't match.
- Log only what matters in production. The
cacheStatusPluginabove, or ahandlerDidCompleteplugin posting to your analytics endpoint through aQueue, gives you field data without the development build. - Read the IndexedDB databases.
workbox-expirationshows the timestamps that drive eviction;workbox-background-syncshows queued requests. - Use the DevTools toggles carefully. "Update on reload" and "Bypass for network" change lifecycle and routing behavior. Reproduce bugs with both off before concluding anything.
- Check the served worker.
curl -I https://example.com/sw.jsmust showContent-Type: text/javascript(or another JavaScript MIME type) and a shortCache-Control. See service worker security for the headers that matter.
Bundling Workbox service workers¶
Measured with esbuild 0.28.2 against workbox-* 7.4.1, minified, production mode:
| Worker contents | Minified | gzip -9 |
|---|---|---|
precacheAndRoute(self.__WB_MANIFEST) only | 17.4 KB | 5.9 KB |
| Config 3 from Workbox fundamentals: precaching, navigation route, 3 runtime routes, expiration, cacheable-response, catch handler | 29.8 KB | 9.7 KB |
workbox-window (new Workbox().register()) in the page | 5.9 KB | 2.5 KB |
Rules that apply to every bundler:
- Define
process.env.NODE_ENV. This is non-negotiable (see Workbox fundamentals). - Output a classic script (IIFE) unless you register with
{type: "module"}. Module service workers are supported in Chromium 91+, Safari 15+ and Firefox 147+ (support data as of September 2026, MDN). IIFE output avoids the question entirely, andimportScripts()keeps working in it. - Don't code-split the worker. A service worker can't load chunks with dynamic
import(); the specification disallows it in service workers. Inline everything into one file. - Inject the manifest before minifying, or configure the minifier to keep
self.__WB_MANIFESTintact. - Emit the worker at the scope root, or send
Service-Worker-Allowed. See registration and scope.
import resolve from "@rollup/plugin-node-resolve";
import replace from "@rollup/plugin-replace";
import terser from "@rollup/plugin-terser";
// Run after this: workbox injectManifest (swSrc: build/sw.js, swDest: dist/sw.js)
export default {
input: "src/sw.js",
output: { file: "build/sw.js", format: "iife", sourcemap: true },
plugins: [
resolve({ browser: true }),
replace({
preventAssignment: true,
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "production"),
}),
// Keep the injection point readable for workbox-build.
terser({ mangle: { reserved: ["self"] }, compress: { global_defs: {} } }),
],
};
import { build } from "esbuild";
await build({
entryPoints: ["src/sw.js"],
bundle: true,
format: "iife",
target: "es2020",
minify: false, // minify after injectManifest
sourcemap: "linked",
define: { "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "production") },
outfile: "build/sw.js",
});
import { defineConfig } from "vite";
// Separate build for the worker: `vite build -c vite.sw.config.mjs`,
// then run workbox injectManifest on dist/sw.js.
// (vite-plugin-pwa's injectManifest strategy does all of this for you.)
export default defineConfig({
define: { "process.env.NODE_ENV": JSON.stringify("production") },
build: {
emptyOutDir: false,
minify: false,
lib: { entry: "src/sw.js", formats: ["iife"], name: "sw", fileName: () => "sw.js" },
},
});
import { InjectManifest } from "workbox-webpack-plugin";
// InjectManifest compiles swSrc in a child compilation (compileSrc: true),
// applies the parent's mode for process.env.NODE_ENV, then injects.
export const swPlugin = new InjectManifest({
swSrc: "./src/sw.js",
swDest: "sw.js",
exclude: [/\.map$/, /^manifest.*\.js$/],
});
For TypeScript workers, compile with "lib": ["ES2022", "WebWorker"], leave out "DOM" (the two conflict), and declare self in the worker file:
/// <reference lib="webworker" />
import type { PrecacheEntry } from "workbox-precaching";
declare const self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: Array<PrecacheEntry | string>;
};
export {}; // make this file a module so the declaration is local
The vite-plugin-pwa page covers the injectManifest strategy that does the bundling and injection in one step for Vite projects.
Migrating from Workbox to Serwist¶
Serwist is a community fork of Workbox. Its README describes it as "a fork of Workbox that came to be due to its development being stagnated". As of September 2026 its latest release is 9.5.12 (22 July 2026). It merges the Workbox runtime into one serwist package, ships integrations for Next.js (@serwist/next, plus @serwist/turbopack), Vite (@serwist/vite), Nuxt, SvelteKit and webpack, and is ESM-only since 9.0.0. Its build packages require Node.js 18+ and TypeScript 5+ if you use TypeScript.
Should you migrate?¶
Migrate if you use Next.js, since @serwist/next is its most maintained PWA path, or if you want a single-class API, ESM, concurrent precaching (precacheOptions.concurrency, default 10, where Workbox installs one entry at a time) or experimental InstallEvent.addRoutes() support (requestRules). Stay on Workbox if you depend on generateSW or workbox-cli's wizard, or if you use vite-plugin-pwa, which is built on workbox-build. The runtimes are close enough that neither choice locks you in.
API mapping¶
| Workbox 7 | Serwist 9 |
|---|---|
workbox-routing, workbox-strategies, workbox-precaching, workbox-expiration, … | serwist (one package) |
workbox-window (Workbox class) | @serwist/window (Serwist class, same event model) |
workbox-build, workbox-cli, workbox-webpack-plugin | @serwist/build, @serwist/cli, @serwist/webpack-plugin (injectManifest only; there is no generateSW) |
self.__WB_MANIFEST | self.__SW_MANIFEST (default injectionPoint) |
precacheAndRoute(manifest, opts) | new Serwist({ precacheEntries, precacheOptions }) |
registerRoute(capture, handler, method) | serwist.registerCapture(capture, handler, method), or serwist.registerRoute(new Route(...)) |
setDefaultHandler() / setCatchHandler() | serwist.setDefaultHandler() / serwist.setCatchHandler() |
createHandlerBoundToURL() / matchPrecache() / getCacheKeyForURL() | serwist.createHandlerBoundToUrl() / serwist.matchPrecache() / serwist.getPrecacheKeyForUrl() |
cleanupOutdatedCaches() | precacheOptions.cleanupOutdatedCaches: true |
navigationPreload.enable() | navigationPreload: true or enableNavigationPreload() |
clientsClaim() / self.skipWaiting() | clientsClaim: true / skipWaiting: true (else a SKIP_WAITING message listener is added, as in generateSW) |
runtimeCaching: [{urlPattern, handler: "NetworkFirst", options}] (build config) | runtimeCaching: [{matcher, handler: new NetworkFirst({...})}] (runtime config, strategy instances only) |
Queue | BackgroundSyncQueue |
WorkboxPlugin type | SerwistPlugin type (callbacks may return non-promises) |
new PrecacheFallbackPlugin({fallbackURL}) | new PrecacheFallbackPlugin({fallbackUrls: [...], serwist}) (the plugin needs the instance to read its precache), or the constructor's fallbacks: {entries: [...]}, which adds the plugin to every runtimeCaching strategy without its own handlerDidError |
BroadcastUpdatePlugin message meta: "workbox-broadcast-update" | meta: "serwist-broadcast-update" (update page-side listeners that check meta) |
workbox-recipes functions | @serwist/recipes; each takes a serwist instance |
initialize() from workbox-google-analytics | offlineAnalyticsConfig option or initializeGoogleAnalytics({serwist}) |
PrecacheController, Router | serwist/legacy (compatibility only) |
Before and after¶
import { precacheAndRoute, cleanupOutdatedCaches, createHandlerBoundToURL } from "workbox-precaching";
import { registerRoute, NavigationRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies";
import { ExpirationPlugin } from "workbox-expiration";
self.addEventListener("message", (e) => {
if (e.data?.type === "SKIP_WAITING") self.skipWaiting();
});
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
registerRoute(new NavigationRoute(createHandlerBoundToURL("/index.html")));
registerRoute(
({ url }) => url.pathname.startsWith("/api/"),
new NetworkFirst({ cacheName: "api", plugins: [new ExpirationPlugin({ maxEntries: 50 })] }),
);
import { Serwist, NetworkFirst, ExpirationPlugin } from "serwist";
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
precacheOptions: {
cleanupOutdatedCaches: true,
navigateFallback: "/index.html",
},
skipWaiting: false, // adds the SKIP_WAITING message listener
runtimeCaching: [
{
matcher: ({ url }) => url.pathname.startsWith("/api/"),
handler: new NetworkFirst({
cacheName: "api",
plugins: [new ExpirationPlugin({ maxEntries: 50 })],
}),
},
],
});
// Adds install, activate, fetch and message listeners. A fetch listener of
// your own that must get the first chance to respondWith() goes above this.
serwist.addEventListeners();
The Serwist constructor does its setup immediately: skipWaiting or the message listener, clientsClaim, precache list, routes. The install, activate, fetch and message listeners are only added by addEventListeners(). Forgetting that call gives you a worker that never precaches and never routes, without any error.
Migration steps¶
- Replace the dependencies. Remove
workbox-*from the worker bundle and installserwistplus the build integration for your tool. Keepworkbox-windowin the page for now if you like: it talks to any worker that honorsSKIP_WAITING, and the page and worker packages are independent. - Rename the injection point to
self.__SW_MANIFEST, or setinjectionPoint: "self.__WB_MANIFEST"in the Serwist build config to keep the old name. - Move
generateSWconfiguration into code. EveryruntimeCachingentry becomes{matcher, handler: new Strategy({cacheName, plugins})}. The option shortcuts (expiration,cacheableResponse,backgroundSync,broadcastUpdate,rangeRequests,precacheFallback) become explicit plugin instances. - Account for the renamed storage. Serwist's default prefix is
serwist, notworkbox. The first Serwist worker therefore downloads the entire precache again intoserwist-precache-v2-<scope>, andserwist-runtime-<scope>replacesworkbox-runtime-<scope>. Serwist'scleanupOutdatedCachesdeletes any cache whose name contains-precache-and the current scope, so it removesworkbox-precache-v2-<scope>. Delete the old runtime caches (workbox-runtime-…and your named caches, if you rename them) yourself inactivate. Expiration metadata moves to theserwist-expirationdatabase, so entries in caches you keep start their expiration clock fresh. Background sync moves to theserwist-background-syncdatabase and tag prefix. Requests still queued inworkbox-background-syncare never replayed by Serwist. Broadcast-update messages change theirmetatoserwist-broadcast-update, so a page listener like the one above that checksmetagoes silent until you update it. - Drain queues first. Ship one last Workbox release in which the page asks the worker to replay the queue on startup (
queue.replayRequests()from amessagehandler) and reports whether it is empty. Switch once the queues are empty for your active users, or accept the loss for low-value data such as analytics. - Retest update flows. The first Serwist deploy is a normal worker update. Check your prompt or auto-update path, and check the size of the one-time precache download on metered connections.
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
for (const name of await caches.keys()) {
if (name.startsWith("workbox-")) await caches.delete(name);
}
// Expiration metadata of the old caches is now meaningless.
await new Promise((resolve) => {
const req = indexedDB.deleteDatabase("workbox-expiration");
req.onsuccess = req.onerror = req.onblocked = () => resolve();
});
})(),
);
});
Common pitfalls¶
- Async match callbacks. A route's match function must return synchronously. A promise is truthy, so an
asyncmatcher matches every request. - Plugins on the wrong strategy instance. Plugins belong to the strategy instance, not the route. Reusing one strategy instance across routes shares its plugins and cache.
- Mutating the response in
cacheWillUpdatewithout buffering. Constructingnew Response(response.body)from a response whose body is also being streamed to the page fails with "body already used" if you forget to clone. Always read fromresponse.clone(). - Expecting
BackgroundSyncPluginto retry5xxresponses. It never will, unless afetchDidSucceedplugin throws. - Caching media through
CacheFirst. The network answersRangerequests with206, which can never be cached. The route silently never fills. offlineFallback()plus your ownsetCatchHandler(). The last call wins.- Assuming broadcast messages arrive. With no
ETag,Last-ModifiedorContent-Lengthon both responses, none are sent. Navigation-triggered messages can be lost if the worker stops during the wait. - A
Queueconstructed insidefetch. The first request works, and the second throwsduplicate-queue-name.
Further reading¶
On this site
- Workbox fundamentals
- Caching strategies
- Background Sync
- Offline-first data and sync
- Messaging and the Clients API
- Storage quotas and persistence
- Vite PWA plugin
- Framework integrations
External references