Skip to content

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 private state object for that request. Returning null from cacheWillUpdate or cachedResponseWillBeUsed means "don't cache" or "treat as a miss".
  • fetchDidFail fires only for network errors. HTTP 4xx/5xx responses count as successes everywhere: in fetchDidFail, in BackgroundSyncPlugin, and in Queue.replayRequests(). If you want a 503 treated as a failure, throw from fetchDidSucceed.
  • A custom strategy must go through handler.fetch(), handler.cacheMatch() and handler.cachePut(). Calling fetch() or caches directly silently bypasses every plugin, navigation preload, and waitUntil() bookkeeping.
  • RangeRequestsPlugin can only slice a complete 200 response that is already in the cache. A 206 from the network can never be stored, because Cache.put() rejects partial responses. Cache media explicitly, without a Range header.
  • BroadcastUpdatePlugin compares only Content-Length, ETag and Last-Modified by 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 to self.__SW_MANIFEST, and uses serwist- 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:

  1. Creates a StrategyHandler(strategy, {event, request, params}). The constructor snapshots strategy.plugins, creates an empty state object per plugin, and calls event.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 a respondWith()/waitUntil() promise is pending. Otherwise the constructor throws InvalidStateError. (Callbacks are later iterated from the live strategy.plugins array, so a plugin pushed onto a strategy mid-request runs with state set to undefined. Add plugins at construction time only.)
  2. Calls _getResponse(). It runs handlerWillStart, then your _handle(). If _handle() threw, or returned nothing or a Response.error(), it runs handlerDidError plugins until one returns a response. Then it runs every handlerWillRespond plugin, each receiving the previous one's response.
  3. Calls _awaitComplete() in parallel. It waits for the response, runs handlerDidRespond, then doneWaiting(), which loops until every promise passed to handler.waitUntil() has settled (including promises added while waiting). Then it runs handlerDidComplete with any error from those promises, and finally destroy(), 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:

  • handlerDidError fires only when the strategy produced no usable response. It doesn't fire for a 404 or a 500 from the network. Those are valid responses. It is what PrecacheFallbackPlugin uses, and it runs before any route or global catch handler. If a plugin supplies a response, the catch handlers never see the error.
  • handlerWillRespond is the last chance to transform the response the page receives, for example to add a header. You can't mutate headers on a Response returned by fetch() (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.
  • handlerDidComplete is the only callback that sees errors from background work: a failed cache write, a quota error, or a rejected handler.waitUntil() promise. Log them here.

Cache-level callbacks

  • cacheKeyWillBeUsed runs for every cache read and write, and receives mode. 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-precaching uses this hook to map a request URL to its ?__WB_REVISION__= key.
  • cachedResponseWillBeUsed runs after caches.match(), even on a miss (cachedResponse is undefined). Returning null turns a hit into a miss. ExpirationPlugin returns null for responses whose Date header is older than maxAgeSeconds. RangeRequestsPlugin returns a sliced 206.
  • cacheWillUpdate decides cacheability. When a strategy has no plugin with this callback, StrategyHandler caches only status === 200. NetworkFirst and StaleWhileRevalidate insert a built-in plugin that also allows status === 0 (opaque), but only if you didn't provide one of your own. Returning null from any plugin stops the chain, and cachePut() resolves false.
  • cacheDidUpdate gets oldResponse only if at least one plugin implements the callback. Only then does cachePut() look up the previous entry, matching while ignoring the __WB_REVISION__ parameter. BroadcastUpdatePlugin and ExpirationPlugin both use it.

Network-level callbacks

  • requestWillFetch can rewrite the outgoing request: add an Authorization header from IndexedDB, switch to a CDN host, or add a cache-busting parameter. It receives a clone. Return a new Request, don't mutate the clone. It is not called when a navigation is satisfied by event.preloadResponse, because StrategyHandler.fetch() returns the preload response before running plugins.
  • fetchDidSucceed receives every response that fetch() resolved with, including 404 and 500. Throwing here makes the request behave as if the network failed. The throw happens inside StrategyHandler.fetch()'s try block, so fetchDidFail plugins run next. The strategy then falls back (for example, NetworkFirst serves the cache), and BackgroundSyncPlugin queues the request.
  • fetchDidFail receives originalRequest, the request before any requestWillFetch rewrite, and request, the rewritten one. StrategyHandler only clones the original request when some plugin implements fetchDidFail, 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

src/plugins/cache-status.js
/**
 * 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

src/plugins/normalize-key.js
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

src/plugins/server-errors-as-failures.js
/**
 * 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:

src/plugins/max-stale.js
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:

  1. Never call fetch() or caches directly. Doing so bypasses plugins, navigation preload and lifetime extension. If you need a different cache, create a second strategy instance and call its handle().
  2. Throw, or return undefined, when you have nothing. The base class converts undefined and Response.error() into WorkboxError('no-response'), which triggers handlerDidError and the catch handlers. Don't return a synthetic 503 unless the page should really see one.
  3. Register background work with handler.waitUntil(), not with event.waitUntil() directly. Only handler.waitUntil() holds handlerDidComplete back until the work finishes.
  4. 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.

src/strategies/cache-network-race.js
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.

src/strategies/fresh-while-revalidate.js
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:

src/sw.js (excerpt)
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 with setCatchHandler(). If you call setCatchHandler() yourself afterwards, you replace the recipe, and vice versa. Its handler checks matchPrecache(fallback) first and then its own workbox-offline-fallbacks cache, which it fills in its own install listener with cache.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.html if the worker isn't at the root.
  • warmStrategyCache() runs during install. Every warm URL is fetched through the strategy, and therefore through its plugins, before the worker can activate. A cache write that fails (for example QuotaExceededError) makes the install fail. A network failure for one URL does not, because handleAll()'s second promise only rejects on waitUntil errors.
  • 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:

src/sw.js
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:

  1. Cache.put() rejects 206 Partial Content responses (the Cache API specification requires it), and Workbox's default cacheability rule only stores 200. A network response to a Range request therefore can't be cached. You have to store the full file some other way.
  2. The cache holds a full 200, but the element expects a 206 with a Content-Range header. RangeRequestsPlugin bridges the two in cachedResponseWillBeUsed. When the request has a Range header and a cached response exists, it calls createPartialResponse().
  3. 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. Add crossorigin="anonymous" to the element, and have the media server send Access-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= (otherwise unit-must-be-bytes) and contain a single range. Multi-range requests (bytes=0-99,200-299) are rejected with single-range-only.
  • bytes=500-, bytes=-500 (suffix length) and bytes=0-999 are supported. The end is inclusive, as in HTTP.
  • It reads the entire cached body into a Blob, slices it, and returns a 206 with Content-Range: bytes start-end/total and a matching Content-Length. All other headers are copied from the cached response.
  • On any parsing or boundary error, it returns an empty 416 Range Not Satisfiable rather 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:

src/sw.js (excerpt)
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:

  1. It does nothing unless cacheDidUpdate supplied an oldResponse. The first write of a URL never notifies.
  2. 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 an ETag or Last-Modified.
  3. 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 for event.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 its message listener.
  4. It posts {type: "CACHE_UPDATED", meta: "workbox-broadcast-update", payload} with postMessage() to every window client (notifyAllClients: true, the default). With notifyAllClients: false, it posts only to event.clientId, the page that made the request. The default payload is {cacheName, updatedURL}. Override it with generatePayload(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.

src/sw.js (excerpt)
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,
        }),
      }),
    ],
  }),
);
src/cache-updates.js
/**
 * 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 Queue with the same name in one worker global throws duplicate-queue-name. Create queues at the top level, never inside a fetch handler.
  • Storage: IndexedDB database workbox-background-sync (version 3), object store requests, index queueName. Each entry stores a serialized StorableRequest: the URL, headers, method, referrer, referrerPolicy, mode, credentials, cache, redirect, integrity, keepalive, and the body as an ArrayBuffer. That means request bodies are buffered in full, and streaming uploads can't be queued. Files uploaded with FormData are stored as bytes.
  • Sync tag: workbox-background-sync:<name>. pushRequest() calls registration.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 SyncManager is missing (Firefox, Safari), or with forceSyncFallback: true, the constructor calls onSync immediately. 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 maxRetentionTime are 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:

src/sw.js (excerpt)
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 Accepted for 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:

src/sw.js (excerpt)
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));
});
src/main.js (excerpt)
// 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

src/sw-outbox.js
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 cacheStatusPlugin above, or a handlerDidComplete plugin posting to your analytics endpoint through a Queue, gives you field data without the development build.
  • Read the IndexedDB databases. workbox-expiration shows the timestamps that drive eviction; workbox-background-sync shows 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.js must show Content-Type: text/javascript (or another JavaScript MIME type) and a short Cache-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, and importScripts() 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_MANIFEST intact.
  • Emit the worker at the scope root, or send Service-Worker-Allowed. See registration and scope.
rollup.sw.config.mjs
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: {} } }),
  ],
};
scripts/bundle-sw.mjs
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",
});
vite.sw.config.mjs
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" },
  },
});
webpack.config.mjs (excerpt)
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:

src/sw.ts (header)
/// <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

src/sw.js
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 })] }),
);
src/sw.js
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

  1. Replace the dependencies. Remove workbox-* from the worker bundle and install serwist plus the build integration for your tool. Keep workbox-window in the page for now if you like: it talks to any worker that honors SKIP_WAITING, and the page and worker packages are independent.
  2. Rename the injection point to self.__SW_MANIFEST, or set injectionPoint: "self.__WB_MANIFEST" in the Serwist build config to keep the old name.
  3. Move generateSW configuration into code. Every runtimeCaching entry becomes {matcher, handler: new Strategy({cacheName, plugins})}. The option shortcuts (expiration, cacheableResponse, backgroundSync, broadcastUpdate, rangeRequests, precacheFallback) become explicit plugin instances.
  4. Account for the renamed storage. Serwist's default prefix is serwist, not workbox. The first Serwist worker therefore downloads the entire precache again into serwist-precache-v2-<scope>, and serwist-runtime-<scope> replaces workbox-runtime-<scope>. Serwist's cleanupOutdatedCaches deletes any cache whose name contains -precache- and the current scope, so it removes workbox-precache-v2-<scope>. Delete the old runtime caches (workbox-runtime-… and your named caches, if you rename them) yourself in activate. Expiration metadata moves to the serwist-expiration database, so entries in caches you keep start their expiration clock fresh. Background sync moves to the serwist-background-sync database and tag prefix. Requests still queued in workbox-background-sync are never replayed by Serwist. Broadcast-update messages change their meta to serwist-broadcast-update, so a page listener like the one above that checks meta goes silent until you update it.
  5. Drain queues first. Ship one last Workbox release in which the page asks the worker to replay the queue on startup (queue.replayRequests() from a message handler) 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.
  6. 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.
src/sw.js (excerpt: one-time Workbox cleanup in the first Serwist release)
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 async matcher 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 cacheWillUpdate without buffering. Constructing new 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 from response.clone().
  • Expecting BackgroundSyncPlugin to retry 5xx responses. It never will, unless a fetchDidSucceed plugin throws.
  • Caching media through CacheFirst. The network answers Range requests with 206, which can never be cached. The route silently never fills.
  • offlineFallback() plus your own setCatchHandler(). The last call wins.
  • Assuming broadcast messages arrive. With no ETag, Last-Modified or Content-Length on both responses, none are sent. Navigation-triggered messages can be lost if the worker stops during the wait.
  • A Queue constructed inside fetch. The first request works, and the second throws duplicate-queue-name.

Further reading

On this site

External references