Skip to content

Caching Strategies

A caching strategy is the rule a service worker's fetch handler follows to decide where a response comes from: Cache Storage, the network, or some ordered or parallel combination of the two, plus what gets written back to the cache afterward. The strategy you choose for each class of request determines whether your PWA works offline, how fast repeat visits render, how stale the content can get, and how much storage and bandwidth you burn. This guide covers every canonical strategy with sequence diagrams, complete vanilla implementations, Workbox equivalents and the edge cases that break them in production. It ends with a full router that combines them and a flowchart for choosing one.

Key takeaways

  • There is no single "PWA caching strategy". Route each class of request (navigations, hashed assets, images, fonts, API JSON, third-party, media) to its own strategy and its own cache, each with its own size and age limits.
  • Cache first is for immutable, versioned URLs. Network first with a timeout is for HTML and data that must be fresh when possible. Stale-while-revalidate is for resources where "one visit old" is acceptable.
  • Register background work (cache writes, revalidation) with event.waitUntil() before the promise passed to respondWith() settles. After that, waitUntil() throws InvalidStateError and the worker may be killed mid-write.
  • Cache Storage is not the HTTP cache: it ignores Cache-Control, never expires anything and caches whatever you put(). Only cache status 200 responses unless you have a reason not to, and treat opaque (status 0) responses with suspicion.
  • Every strategy that can fail needs a terminal fallback (offline page, placeholder image, JSON error). A rejected respondWith() promise is a network error, which shows the browser's own offline page for navigations.
  • Stale-while-revalidate can tell open pages that fresher content arrived. Compare validators (ETag, Last-Modified, Content-Length) or a body hash, then postMessage() the affected clients.

How a caching strategy plugs into the fetch event

Every strategy on this page is a function with the same shape: it receives a FetchEvent and returns a Promise<Response> that you hand to event.respondWith(). The browser dispatches a fetch event for every request made by a controlled client, including navigations, subresources and fetch()/XHR calls. The request flow is covered in Handling Fetch Events; the rules that matter for strategies are these:

  • respondWith() must be called synchronously during event dispatch, and at most once. The spec throws InvalidStateError if the dispatch flag is unset (you awaited something first) or if respondWith() was already called. Pass it a promise and do the asynchronous work inside that promise.
  • Not calling respondWith() is a valid strategy. If no listener calls it, the browser performs the request itself exactly as if there were no service worker. This is the cheapest possible "network only".
  • A rejected promise, or a promise that resolves to something other than a Response, becomes a network error. For fetch() in the page that is a TypeError. For a navigation it is the browser's built-in offline or error page, which is the worst outcome a PWA can produce.
  • respondWith() extends the event's lifetime as if waitUntil() had been called with the same promise, so the worker stays alive until the response promise settles. Anything that continues after that point, such as a cache write or a background revalidation, needs its own waitUntil().

The waitUntil window: why background work must be registered early

The Service Workers specification defines an ExtendableEvent as active while its dispatch flag is set or its pending promises count is greater than zero. waitUntil(promise) throws InvalidStateError when the event is not active. respondWith(r) adds r to the event's lifetime promises, so while the response promise is pending you can call waitUntil() from any later task. The moment that promise settles and nothing else is pending, the count drops to zero, the event becomes inactive, and a later waitUntil() call throws. The spec's own note puts it this way: if no lifetime extension promise has been added in the task that called the event handlers, calling waitUntil() in subsequent asynchronous tasks will throw.

That has a direct consequence for strategies that answer from one source and keep working on another, such as stale-while-revalidate or a cache/network race. This code looks reasonable and is broken:

sw.js (broken)
self.addEventListener("fetch", (event) => {
  event.respondWith((async () => {
    const cached = await caches.match(event.request);
    const network = fetch(event.request);
    if (cached) {
      // BUG: this .then() runs after respondWith()'s promise has settled.
      // If nothing else is pending, waitUntil() throws InvalidStateError,
      // and the browser is free to terminate the worker before put() finishes.
      network.then((response) => {
        event.waitUntil(caches.open("v1").then((c) => c.put(event.request, response)));
      });
      return cached;
    }
    return network;
  })());
});

The fix is to register background work while the response promise is still pending. Workbox does it structurally: the StrategyHandler constructor calls event.waitUntil() on a deferred promise the moment a strategy starts handling a request, and resolves it only after handler.doneWaiting(), which loops until every promise passed to handler.waitUntil() (including ones added late) has settled. strategy.handleAll() exposes the same pair as [responseDone, handlerDone]. The vanilla helpers below do the simpler thing: every helper that starts background work calls event.waitUntil() immediately, while the strategy's own promise is still pending.

Cache Storage is not the HTTP cache

Two independent caches sit between a page and the origin, and a strategy only controls one of them directly:

Cache Storage (caches) HTTP cache
Who writes entries Your code, explicitly, via put(), add() or addAll() The browser, following Cache-Control, Expires, validators
Expiration Never. The spec: "The Cache objects do not expire unless authors delete the entries." Freshness lifetime from headers, heuristic freshness, eviction
Honors no-store, private, max-age No. It stores what you give it Yes
Survives a service worker update Yes. Caches are not tied to a worker version Yes
Visible to fetch() in the worker Only through caches.match() Yes: fetch() in a service worker goes through the HTTP cache

The last row matters for revalidation. When a stale-while-revalidate strategy calls fetch(request) to refresh an entry, that request can be answered from the HTTP cache without touching the origin, if the HTTP cache still considers its copy fresh. To force a conditional request to the origin, fetch with cache: "no-cache" (the HTTP cache revalidates with If-None-Match / If-Modified-Since, and a 304 is transparently turned into the stored 200). See HTTP Caching & Service Workers for how to set headers so the two layers cooperate instead of stacking staleness.

What should be written to the cache

Cache.put() enforces only a few rules. Per the Service Workers specification, it rejects with a TypeError when the request URL's scheme is not http or https, when the request method is not GET, when the response status is 206, when the response has a Vary: * header, or when the response body is already disturbed or locked. Everything else is stored, including 404 pages, 500 error pages, opaque responses and responses marked Cache-Control: no-store. add() and addAll() are stricter: they reject if a response is not in the 200–299 range or is a 206, which is why they can't store opaque responses. addAll() is also atomic: if any request fails, nothing is stored.

Deciding what is worth caching is therefore your policy. The Workbox defaults are a reasonable baseline and worth knowing exactly: with no cacheWillUpdate plugin, CacheFirst only caches status 200, while StaleWhileRevalidate and NetworkFirst add a built-in plugin that caches status 0 (opaque) and 200. NetworkOnly and CacheOnly never write.

Deep dive: why opaque responses are a special case

A cross-origin request made in no-cors mode, which is the default for <img>, <script>, <link rel="stylesheet">, <video> and <audio> without a crossorigin attribute, produces an opaque response: type is "opaque", status is 0, ok is false, headers are empty and the body can't be read. Your service worker can store it and return it to the same kind of no-cors request, but it can't tell a 200 from a 404 or a 500. Cache it with cache first and a transient error page from a CDN can be served forever.

Opaque responses also cost more quota than their size. To avoid leaking the size of cross-origin resources, browsers pad them for quota accounting. In Chromium each cached opaque response is charged a pseudo-random padding between 0 and about 14 MiB, about 7 MiB on average, on top of its real size. A few hundred cached third-party thumbnails count as gigabytes against your quota. When you control the third party, add crossorigin="anonymous" to the element and serve Access-Control-Allow-Origin: you get a readable CORS response with a real status and real headers.

Finally, when Cache.match() or matchAll() is about to return an opaque response, the spec runs a Cross-Origin-Resource-Policy check against the calling context and rejects with a TypeError if it is blocked. In practice this bites cross-origin isolated pages (Cross-Origin-Embedder-Policy: require-corp): an opaque entry stored without a permissive Cross-Origin-Resource-Policy header can't be read back.

Shared helpers used by every strategy

The implementations on this page share one small module. It assumes a module service worker, registered with navigator.serviceWorker.register("/sw.js", { type: "module" }), which Chrome supports from 91, Safari from 15 and Firefox from 147. If you need a classic script, bundle the modules into one file (any bundler can emit a non-module build).

sw/lib/cache-helpers.js
// Shared primitives for every strategy on this page.

export const CACHE_VERSION = "v1";

// One cache per resource class: each gets its own size and age policy,
// and each can be purged on its own when storage runs out.
export const CACHES = Object.freeze({
  shell: `shell-${CACHE_VERSION}`, // precached, versioned app shell + offline page
  pages: "pages",                  // HTML navigations
  immutable: "immutable-assets",   // content-hashed CSS/JS
  assets: "assets",                // unhashed CSS/JS
  images: "images",
  fonts: "fonts",
  api: "api",
  thirdParty: "third-party",
  media: "media",                  // audio/video the user explicitly downloaded
});

// Caches that can be dropped wholesale when a write hits the quota.
// Never list caches holding data the user asked to keep (CACHES.media).
const PURGEABLE = [CACHES.images, CACHES.thirdParty, CACHES.assets];

export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

/**
 * Policy: is this response worth storing?
 * Cache.put() itself only rejects 206, Vary: *, non-GET and non-http(s);
 * everything else here is a deliberate choice.
 */
export function isCacheable(response, { allowOpaque = false } = {}) {
  if (!response) return false;
  if (response.type === "opaque") return allowOpaque; // status 0, unreadable
  if (response.type === "error" || response.type === "opaqueredirect") return false;
  if (response.status !== 200) return false; // no 404/500 pages, no 206 partials
  const vary = response.headers.get("Vary");
  if (vary && vary.split(",").some((field) => field.trim() === "*")) return false;
  // Respect an explicit instruction not to store this response anywhere.
  if (/\bno-store\b/i.test(response.headers.get("Cache-Control") ?? "")) return false;
  return true;
}

/**
 * Write a response without ever letting a failure reach the response path.
 * Resolves to true when the entry was stored.
 */
export async function putInCache(cacheName, request, response) {
  // Yield a task first. Workbox's StrategyHandler.cachePut() does the same
  // ("run in the next task to avoid blocking other cache reads", citing
  // w3c/ServiceWorker#1397): a cache write started immediately can queue
  // ahead of the cache.match() calls other requests are waiting on.
  await sleep(0);
  try {
    const cache = await caches.open(cacheName);
    await cache.put(request, response);
    return true;
  } catch (error) {
    if (error?.name === "QuotaExceededError") {
      await Promise.all(PURGEABLE.map((name) => caches.delete(name)));
    }
    console.warn("[sw] cache.put failed:", request.url ?? request, error);
    return false;
  }
}

/**
 * Network fetch that prefers the navigation preload response for navigations.
 */
export async function fromNetwork(event, request = event.request, init) {
  if (request === event.request && request.mode === "navigate" && event.preloadResponse) {
    try {
      const preloaded = await event.preloadResponse; // undefined if preload is disabled
      if (preloaded) return preloaded;
    } catch {
      // The preload failed (offline, aborted). Fall through: fetch() will
      // fail the same way if the network is really gone.
    }
  }
  // Never pass a RequestInit with a navigation request. The Request constructor
  // turns mode "navigate" into "same-origin" when init is non-empty (so the
  // server sees Sec-Fetch-Mode: same-origin), and older Chromium threw a TypeError.
  return request.mode === "navigate" ? fetch(request) : fetch(request, init);
}

/**
 * fetch() with a deadline. Rejects with a TimeoutError DOMException.
 */
export function fetchWithTimeout(event, request, timeoutMs, init = {}) {
  if (!timeoutMs) return fromNetwork(event, request, init);
  if (request.mode === "navigate") {
    // A signal needs a RequestInit, which would break navigate mode: race a timer instead.
    let timer;
    const deadline = new Promise((_, reject) => {
      timer = setTimeout(
        () => reject(new DOMException(`No response after ${timeoutMs} ms`, "TimeoutError")),
        timeoutMs,
      );
    });
    return Promise.race([fromNetwork(event, request), deadline]).finally(() => clearTimeout(timer));
  }
  // AbortSignal.timeout() actually cancels the request (and frees the socket).
  return fromNetwork(event, request, { ...init, signal: AbortSignal.timeout(timeoutMs) });
}

/**
 * Fetch, then write a copy to `cacheName` in the background.
 * The write is registered with waitUntil() synchronously, while the strategy's
 * promise (and therefore respondWith()) is still pending.
 */
export function fetchAndCache(event, request, cacheName, { allowOpaque = false, init } = {}) {
  const network = fromNetwork(event, request, init);
  // This reaction is registered before the caller awaits `network`, so clone()
  // runs before anyone starts reading the body.
  const written = network.then(
    (response) =>
      isCacheable(response, { allowOpaque })
        ? putInCache(cacheName, request, response.clone())
        : false,
    () => false,
  );
  event.waitUntil(written);
  return network;
}

Three details in that module deserve emphasis because they are where most hand-written strategies go wrong. First, response.clone() must happen before the body is consumed; a Response body is a stream that can be read once, and clone() on a used body throws TypeError. Second, fetchAndCache() returns the network response immediately and lets the cache write finish in the background. Awaiting cache.put() before returning would hold the response until the entire body had been downloaded and written, turning a streaming response into a buffered one. Third, putInCache() swallows errors: a quota error or a malformed response must never convert a successful network response into a failure.

Strategy overview

Strategy Reads from Writes to cache Freshness Offline Typical use
Cache only Cache Never (precache fills it) Only as fresh as the last install Yes, if precached Versioned app shell, offline page
Network only Network Never Always fresh No Non-GET, auth, analytics, payments, live data
Cache first Cache, then network on miss On miss Frozen until evicted Yes, after first load Hashed assets, fonts, immutable images
Network first Network, then cache on failure/timeout On every success Fresh when online Yes, after first load HTML navigations, important API data
Stale-while-revalidate Cache, network in background On every success One request behind Yes, after first load Avatars, unhashed CSS/JS, non-critical API data
Cache then network Both, rendered twice (page side) On every success Fresh after network arrives Shows cached data Feeds, dashboards, inboxes
Race Cache and network in parallel On network success Whichever wins Yes, after first load Small assets on devices with slow storage
Generic fallback Any of the above, then a canned response Precache only n/a Always something Terminal handler for every route
Cache with expiration Any cache-writing strategy With limits Bounded age Until expired Images, third-party, anything unbounded

Cache only

Cache only answers exclusively from Cache Storage and never touches the network. It is only correct for URLs you guarantee are in the cache, which in practice means files precached during install, where the service worker refuses to install unless every file was stored.

How cache only works

sequenceDiagram
    participant Page
    participant SW as Service worker
    participant Cache as Cache Storage
    Page->>SW: GET /app.3f9a2c1d.js
    SW->>Cache: match(request)
    alt entry found
        Cache-->>SW: Response
        SW-->>Page: Response from cache
    else miss
        Cache-->>SW: undefined
        SW-->>Page: 504 synthetic response
    end

Implementing cache only

sw/strategies/cache-only.js
import { CACHES } from "../lib/cache-helpers.js";

/**
 * Cache only. A miss is a bug (the router and the precache list disagree),
 * so it fails loudly instead of silently going to the network.
 */
export async function cacheOnly(event, { cacheName = CACHES.shell, matchOptions } = {}) {
  // Open the specific cache: caches.match() without a cacheName searches
  // every cache in creation order, which is slower and can return an entry
  // from a stale cache you forgot to delete.
  const cache = await caches.open(cacheName);
  const cached = await cache.match(event.request, matchOptions);
  if (cached) return cached;

  console.error(`[sw] cache-only miss for ${event.request.url} in "${cacheName}"`);
  // A real Response (not Response.error()) lets page code inspect the failure.
  return new Response("", { status: 504, statusText: "Not in cache" });
}
sw.js
import { registerRoute } from "workbox-routing";
import { CacheOnly } from "workbox-strategies";

// CacheOnly never writes. Something else (precaching, warmStrategyCache,
// a user action) must have filled the cache.
registerRoute(
  ({ url, sameOrigin }) => sameOrigin && url.pathname.startsWith("/app/v2/"),
  new CacheOnly({ cacheName: "app-v2" }),
);

// For precached build output you don't need a route at all:
// precacheAndRoute(self.__WB_MANIFEST) registers a cache-only route
// (with URL normalization and revision handling) for every precached URL.

When to use cache only

  • Files listed in your precache manifest: the versioned app shell, the offline page, fallback images. In Workbox, precacheAndRoute() already serves these cache-only, so you rarely write this route yourself.
  • Assets the user explicitly saved for offline use (a downloaded podcast episode, an offline map region) that must never cost mobile data once saved.
  • Any request where going to the network would be wrong, for example a kiosk build that must render exactly the version it installed.

Cache only pitfalls

  • Query strings and URL variants miss. /app.js and /app.js?v=3 are different cache keys. Build tools and CDNs add cache-busting parameters; so do analytics tags (?utm_source=...) on navigations. Use ignoreSearch: true only when the query truly doesn't change the response, and never for API routes where it does.
  • Vary headers can make a precached entry unmatchable. Matching compares every request header named in the stored response's Vary between the stored request and the lookup request. If the server sends Vary: Accept, an entry stored by cache.addAll() from a plain URL string won't match the browser's later stylesheet request, which carries a stylesheet-specific Accept value. Strip Vary for static assets on the server or pass ignoreVary: true.
  • Redirected responses break navigations. If / redirects to /index.html during precaching, the stored response has redirected === true. Serving it for a navigation, whose redirect mode is manual, fails with a network error. Precache the final URL, or copy the response into a fresh Response before storing it (shown in Common pitfalls).

Network only

Network only sends the request to the network and returns whatever comes back, never reading or writing Cache Storage. It is the right answer for anything that must not be cached, and it is often best implemented by not handling the request at all.

How network only works

sequenceDiagram
    participant Page
    participant SW as Service worker
    participant Net as Network
    Page->>SW: POST /api/orders
    alt no respondWith call
        SW-->>Page: listener returns, event not handled
        Note over Page,Net: The browser sends the request natively
    else respondWith with fetch
        SW->>Net: fetch(request)
        Net-->>SW: 201 Created
        SW-->>Page: 201 Created
    end

Implementing network only

sw/strategies/network-only.js
import { fetchWithTimeout } from "../lib/cache-helpers.js";

/**
 * Network only, with an optional deadline.
 * Prefer returning early from the fetch listener (no respondWith) when you
 * don't need the timeout: the browser then handles the request natively.
 */
export function networkOnly(event, { timeoutMs = 0 } = {}) {
  return fetchWithTimeout(event, event.request, timeoutMs);
}
sw.js (excerpt)
self.addEventListener("fetch", (event) => {
  const { request } = event;
  // Cheapest network only: don't call respondWith() at all.
  if (request.method !== "GET") return;
  if (new URL(request.url).pathname.startsWith("/auth/")) return;
  // ...other routes
});
sw.js
import { registerRoute } from "workbox-routing";
import { NetworkOnly } from "workbox-strategies";

// Workbox routes are registered per HTTP method (default "GET").
registerRoute(
  ({ url }) => url.pathname.startsWith("/api/"),
  new NetworkOnly({ networkTimeoutSeconds: 10 }),
  "POST",
);

// Anything that matches no route and has no default handler is not
// handled by Workbox, which is the same as the early return above.

NetworkOnly in Workbox 7 accepts plugins, fetchOptions and networkTimeoutSeconds; it deliberately omits cacheName and matchOptions because it never touches a cache. Its timeout is implemented as a Promise.race() against a timer, so the underlying request is not aborted; the vanilla version above uses AbortSignal.timeout() for non-navigation requests, which does cancel it.

When to use network only

  • Non-GET requests. The Cache API can't store them (put() rejects any method other than GET). If a POST must survive going offline, queue it with Background Sync rather than caching it.
  • Authentication, session, payment and checkout endpoints, where a cached answer is either a security problem or a correctness problem. See Service Worker Security.
  • Analytics beacons and real-time data (prices, stock levels, live scores) where a stale value is worse than none.
  • Server-Sent Events and long-polling endpoints, whose responses never end; the Cache API buffers the whole body before committing it.

Network only pitfalls

  • A fetch listener that does nothing still costs you. Even if it returns without calling respondWith(), the browser must start the service worker and run the listener before the request proceeds. Chrome 112 added a console warning for listeners that are completely empty ("no-op"), and Chrome 115 started skipping them, but a listener with any logic in it is always run. For high-traffic network-only paths, the Static Routing API (event.addRoutes() with source: "network", Chromium 123+ and Safari 27+) bypasses the worker entirely.
  • Wrapping in respondWith(fetch(request)) changes the error surface. If the fetch fails, the page sees a generic network error instead of the browser's native handling. Only wrap when you add value: a timeout, a fallback, logging.
  • Timeouts on non-idempotent requests are dangerous. Aborting a POST on the client doesn't abort it on the server; the order may still be placed. Use timeouts for reads, and idempotency keys for writes.

Cache first (cache falling back to network)

Cache first looks in the cache and only goes to the network on a miss, storing the network response for next time. After the first load the resource never costs a network round trip again, which is exactly right for URLs whose content never changes and exactly wrong for everything else.

How cache first works

sequenceDiagram
    participant Page
    participant SW as Service worker
    participant Cache as Cache Storage
    participant Net as Network
    Page->>SW: GET /fonts/inter.3b1c.woff2
    SW->>Cache: match(request)
    alt hit
        Cache-->>SW: Response
        SW-->>Page: Response from cache, no network
    else miss
        Cache-->>SW: undefined
        SW->>Net: fetch(request)
        Net-->>SW: 200 OK
        SW-->>Page: Response
        SW->>Cache: put(request, clone) in waitUntil
    end

Implementing cache first

sw/strategies/cache-first.js
import { fetchAndCache } from "../lib/cache-helpers.js";

/**
 * Cache first: serve from cache, go to the network only on a miss.
 * allowOpaque defaults to false because a cached opaque error is permanent.
 */
export async function cacheFirst(event, { cacheName, allowOpaque = false, matchOptions } = {}) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(event.request, matchOptions);
  if (cached) return cached;
  // Miss: fetch, respond, and store a copy in the background.
  // Network errors propagate so the router's fallback can handle them.
  return fetchAndCache(event, event.request, cacheName, { allowOpaque });
}
sw.js
import { registerRoute } from "workbox-routing";
import { CacheFirst } from "workbox-strategies";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
import { ExpirationPlugin } from "workbox-expiration";

registerRoute(
  ({ request, sameOrigin }) => sameOrigin && request.destination === "font",
  new CacheFirst({
    cacheName: "fonts",
    plugins: [
      // CacheFirst only caches status 200 by default; stating it documents intent.
      new CacheableResponsePlugin({ statuses: [200] }),
      new ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 365 * 24 * 60 * 60 }),
    ],
  }),
);

When to use cache first

  • Content-hashed build output (app.3f9a2c1d.js, styles.8e21.css): a new version is a new URL, so a cached entry can never be stale. Serve these with Cache-Control: public, max-age=31536000, immutable as well.
  • Web fonts, which are almost always versioned and are fetched in CORS mode (so the response is readable and its status can be checked).
  • Images whose URL changes when the content changes: product photos with IDs, user uploads with content hashes, CDN URLs with transformation parameters.
  • Anything you'd accept being stuck on a version until you explicitly purge it, combined with an expiration policy so the cache doesn't grow forever.

Cache first pitfalls

  • Unversioned URLs get stuck. /styles.css served cache first will never update until the entry is deleted, even after you deploy and even after the service worker updates, because caches outlive worker versions. Either version the URL or use stale-while-revalidate.
  • Opaque responses make errors permanent. A cross-origin no-cors image that returned a 503 during a CDN incident is stored as an indistinguishable opaque response and served forever. Keep allowOpaque off for cache first, and use CORS (crossorigin attribute) or stale-while-revalidate for third-party assets. The Workbox runtime caching guide recommends network first or stale-while-revalidate for opaque responses for the same reason.
  • Unbounded growth. Every distinct URL adds an entry. Pair cache first with maxEntries/maxAgeSeconds, especially for images, and remember that superseded hashed files pile up too: after five deploys you hold five copies of the bundle unless something evicts the old ones.
  • Cache first may not keep the browser off the network. Chrome 154 (stable since September 22, 2026) includes an optional browser optimization, ServiceWorkerAutoPreload, in which the browser may issue the network request in parallel with service worker startup. Per its Chrome Platform Status entry, if the handler responds with respondWith(), the network result is consumed inside the fetch handler; if the handler falls back, the network response goes straight to the browser. A cache-first handler that answers from cache simply doesn't use it, but your origin can still see the request, so don't treat "served from cache" as "no server traffic" when capacity planning. Chrome applies it only by heuristics (for example, for sites whose handler usually falls back, or when the worker isn't already running), and not when you have enabled navigation preload yourself. The ServiceWorkerAutoPreloadEnabled enterprise policy, available from Chrome 140 during the rollout, is listed on Chrome Platform Status for removal in Chrome 154.

Network first (network falling back to cache) with a timeout

Network first always tries the network, stores successful responses, and falls back to the cached copy when the network fails. It gives users the freshest content whenever they are online and the last good copy when they are not. On its own it has one serious flaw: a network that is slow rather than down (a captive portal, one bar of signal, a congested train Wi-Fi, often called "lie-fi") doesn't make fetch() reject. It just hangs, often far longer than any user will wait, while the user stares at a blank screen with a perfectly good cached page available. A timeout fixes that.

How network first with a timeout works

sequenceDiagram
    participant Page
    participant SW as Service worker
    participant Net as Network
    participant Cache as Cache Storage
    Page->>SW: navigate to /articles/42
    SW->>Net: fetch or preloadResponse
    SW->>SW: start 3 s timer
    alt network answers first
        Net-->>SW: 200 OK
        SW-->>Page: fresh response
        SW->>Cache: put clone in waitUntil
    else timer fires and cache has an entry
        SW->>Cache: match(request)
        Cache-->>SW: cached response
        SW-->>Page: cached response
        Net-->>SW: late 200 OK
        SW->>Cache: put clone, refreshed for next time
    else network fails
        Net--xSW: TypeError
        SW->>Cache: match(request)
        Cache-->>SW: cached response or undefined
        SW-->>Page: cached response or fallback
    end

Note the middle branch: when the timeout wins, the network request is not abandoned. It keeps running inside waitUntil(), and when it completes the cache is refreshed, so the next visit gets the newer copy. That is the behavior of Workbox's NetworkFirst too, and it's usually what you want. The alternative, aborting the request at the deadline, saves bandwidth but guarantees the cache never improves on a slow connection.

Implementing network first with a timeout

sw/strategies/network-first.js
import { fetchAndCache } from "../lib/cache-helpers.js";

/**
 * Network first with an optional timeout.
 * - Network answers within timeoutMs: respond with it and refresh the cache.
 * - Network fails (or returns 5xx and fallbackOn5xx is set): respond from cache.
 * - Network is slow: after timeoutMs respond from cache if there's an entry;
 *   the request keeps running in waitUntil() and refreshes the cache.
 */
export async function networkFirst(event, {
  cacheName,
  timeoutMs = 0,
  allowOpaque = false,
  fallbackOn5xx = true,
  matchOptions,
} = {}) {
  const { request } = event;
  // Start the network request before any await, so opening the cache
  // doesn't delay it.
  const network = fetchAndCache(event, request, cacheName, { allowOpaque });
  const cache = await caches.open(cacheName);
  const fromCache = () => cache.match(request, matchOptions);

  const networkOrCache = network.then(
    async (response) => {
      // An overloaded origin returning 503 is an outage from the user's
      // point of view: prefer the last good copy when there is one.
      if (fallbackOn5xx && response.status >= 500) return (await fromCache()) ?? response;
      return response;
    },
    async (error) => {
      const cached = await fromCache();
      if (cached) return cached;
      throw error; // nothing cached: let the router's fallback decide
    },
  );

  if (!timeoutMs) return networkOrCache;

  let timer;
  const cacheAfterTimeout = new Promise((resolve) => {
    timer = setTimeout(async () => {
      // Resolve only on a hit. On a miss (or a failed lookup) this promise
      // never settles and the race keeps waiting for the network, which is
      // still the best source.
      const cached = await fromCache().catch(() => undefined);
      if (cached) resolve(cached);
    }, timeoutMs);
  });

  try {
    return await Promise.race([networkOrCache, cacheAfterTimeout]);
  } finally {
    clearTimeout(timer);
  }
}
sw.js
import { registerRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
import * as navigationPreload from "workbox-navigation-preload";

// Strategies use event.preloadResponse automatically for navigations,
// but navigation preload still has to be enabled.
navigationPreload.enable();

// Throwing from fetchDidSucceed turns a 5xx into a "network failure",
// which makes NetworkFirst fall back to the cache.
const fallbackOn5xx = {
  fetchDidSucceed: async ({ response }) => {
    if (response.status >= 500) throw new Error(`HTTP ${response.status}`);
    return response;
  },
};

registerRoute(
  ({ request }) => request.mode === "navigate",
  new NetworkFirst({
    cacheName: "pages",
    networkTimeoutSeconds: 3,
    plugins: [
      // NetworkFirst would cache status 0 and 200 by default; pages are
      // same-origin, so only 200 is meaningful.
      new CacheableResponsePlugin({ statuses: [200] }),
      fallbackOn5xx,
    ],
  }),
);

The Workbox implementation is worth reading because its edge-case behavior is well tested. NetworkFirst._handle() races two promises: a timeout promise that resolves to handler.cacheMatch(request) after networkTimeoutSeconds, and a network promise that calls handler.fetchAndCachePut(request) and falls back to cacheMatch() on error. If the race resolves to undefined (the timer fired but the cache was empty), it waits for the network promise instead: (await Promise.race(promises)) || (await networkPromise). When the network wins, it calls clearTimeout(). When everything fails, it throws a no-response WorkboxError, which reaches your setCatchHandler(). The pageCache recipe in workbox-recipes uses exactly this strategy for navigations, with a default networkTimeoutSeconds of 3.

Choosing a timeout value

The timeout trades freshness against perceived speed, and there is no universal number:

  • Navigations: 2 to 4 seconds is common; Workbox's pageCache recipe defaults to 3. Measure your own TTFB distribution and set the timeout above the 95th percentile of healthy responses, so that only genuinely degraded connections fall back.
  • API JSON that drives the UI: often shorter (1 to 3 seconds), because the page can render cached data and then refresh; this is where cache then network shines.
  • Never for non-idempotent requests. A timeout on a POST doesn't cancel the server-side effect.
  • Adaptive timeouts are possible in Chromium through the Network Information API (navigator.connection.effectiveType, saveData), which is also exposed in workers. It is Chromium-only, so treat it as an optimization and keep a sane default for Firefox and Safari.

When to use network first

  • HTML navigations for sites whose pages change (news, e-commerce, dashboards). Serving a cached article from last week is acceptable offline and unacceptable online.
  • API data where freshness matters but offline access is still valuable: order history, account settings, messages.
  • Anything personalized, provided you clear the cache on logout (see Common pitfalls).

Network first pitfalls

  • No timeout means lie-fi hangs. Without a deadline, a stalled TCP connection leaves the page blank until the browser gives up, which can take far longer than any user waits.
  • The first visit is never covered. Network first only has something to fall back to after a successful load. Precache the offline page and use a generic fallback for URLs never visited.
  • Navigation preload interacts with caching. With navigation preload enabled, the network request for a navigation starts in parallel with service worker startup, and fromNetwork() above uses event.preloadResponse. If your server returns a different body for preload requests (it can detect the Service-Worker-Navigation-Preload header, for example to send only the content partial), don't cache that body as the full page, and send Vary: Service-Worker-Navigation-Preload so HTTP caches keep the variants apart. If you enable preload but never consume preloadResponse, Chrome warns in the console that the preload request was cancelled before preloadResponse settled.
  • Every visit writes to the cache. For large HTML pages that is real I/O on every navigation. That is usually fine, but skip the write when the response is identical (compare ETag) if storage writes show up in traces.

Stale-while-revalidate

Stale-while-revalidate (SWR) responds immediately from the cache when it can, and at the same time fetches a fresh copy from the network to update the cache for next time. If there is no cached copy, it waits for the network. The name comes from the HTTP Cache-Control: stale-while-revalidate extension defined in RFC 5861, which gives the HTTP cache the same behavior; a service worker lets you apply it to any request, with your own rules.

How stale-while-revalidate works

sequenceDiagram
    participant Page
    participant SW as Service worker
    participant Cache as Cache Storage
    participant Net as Network
    Page->>SW: GET /api/categories
    SW->>Cache: match(request)
    alt cached copy exists
        Cache-->>SW: stale response
        SW-->>Page: stale response, instantly
        SW->>Net: fetch with cache no-cache, inside waitUntil
        Net-->>SW: 200 OK
        SW->>Cache: put(request, response)
        SW-->>Page: optional CACHE_UPDATED message
    else nothing cached
        SW->>Net: fetch(request)
        Net-->>SW: 200 OK
        SW-->>Page: response
        SW->>Cache: put clone in waitUntil
    end

SWR's defining property is that the user always sees content that is at most one fetch behind: the response they get was current as of their previous request for that URL. For a resource requested on every page view, that's minutes old. For one requested once a month, it's a month old, which is why SWR is often paired with a freshness window or an expiration policy.

Implementing stale-while-revalidate

The vanilla version adds three production features beyond the textbook pattern: an in-flight map so that concurrent requests for the same URL (five tabs, or the same avatar rendered ten times) trigger one revalidation instead of many; a freshForMs window that skips revalidation entirely while the entry is young, equivalent to max-age plus stale-while-revalidate in HTTP; and an onUpdate hook used later to broadcast updates.

sw/strategies/stale-while-revalidate.js
import { fetchAndCache, fromNetwork, isCacheable, putInCache } from "../lib/cache-helpers.js";

// Revalidations in flight, keyed by URL. Lives only as long as this worker
// instance, which is exactly as long as it needs to.
const inflight = new Map();

/** Age from the Date header (origin clock), or Infinity when unknown/opaque. */
export function responseAgeMs(response) {
  const date = Date.parse(response.headers.get("Date") ?? "");
  return Number.isNaN(date) ? Infinity : Math.max(0, Date.now() - date);
}

export async function staleWhileRevalidate(event, {
  cacheName,
  allowOpaque = true, // SWR self-heals on the next request, so opaque is tolerable
  freshForMs = 0,     // skip revalidation while the entry is younger than this
  onUpdate,           // async ({ event, request, cacheName, oldResponse, newResponse })
} = {}) {
  const { request } = event;
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);

  if (!cached) {
    // Cold cache: plain "network, then store". Errors reach the fallback.
    return fetchAndCache(event, request, cacheName, { allowOpaque });
  }

  if (freshForMs > 0 && responseAgeMs(cached) < freshForMs) {
    return cached; // still fresh: no network traffic at all
  }

  // Registered while respondWith() is still pending (see "The waitUntil window").
  event.waitUntil(revalidate(event, cache, cacheName, { allowOpaque, onUpdate }));
  return cached;
}

function revalidate(event, cache, cacheName, { allowOpaque, onUpdate }) {
  const { request } = event;
  const key = request.url;
  if (inflight.has(key)) return inflight.get(key);

  const task = (async () => {
    // cache: "no-cache" forces the HTTP cache to revalidate with the origin
    // instead of answering from its own still-fresh copy. Navigation
    // requests can't take a RequestInit (see fromNetwork), so they don't get it.
    const init = request.mode === "navigate" ? undefined : { cache: "no-cache" };
    const response = await fromNetwork(event, request, init);
    if (!isCacheable(response, { allowOpaque })) return;

    // Read the previous entry *before* overwriting it; onUpdate compares them.
    const oldResponse = onUpdate ? await cache.match(request) : undefined;
    const newResponse = onUpdate ? response.clone() : undefined;
    const stored = await putInCache(cacheName, request, response);
    if (stored && oldResponse) {
      await onUpdate({ event, request, cacheName, oldResponse, newResponse });
    }
  })()
    // Offline or failing origin: the user already has the stale copy.
    .catch((error) => console.info("[sw] revalidation failed, keeping stale copy:", key, error))
    .finally(() => inflight.delete(key));

  inflight.set(key, task);
  return task;
}
sw.js
import { registerRoute } from "workbox-routing";
import { StaleWhileRevalidate } from "workbox-strategies";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
import { ExpirationPlugin } from "workbox-expiration";
import { BroadcastUpdatePlugin } from "workbox-broadcast-update";

registerRoute(
  ({ url, sameOrigin }) => sameOrigin && url.pathname.startsWith("/api/categories"),
  new StaleWhileRevalidate({
    cacheName: "api",
    fetchOptions: { cache: "no-cache" }, // bypass a fresh HTTP-cache copy
    plugins: [
      new CacheableResponsePlugin({ statuses: [200] }),
      new ExpirationPlugin({ maxEntries: 50 }),
      new BroadcastUpdatePlugin(), // posts CACHE_UPDATED when ETag etc. change
    ],
  }),
);

// A freshness window needs a small subclass: StaleWhileRevalidate always
// revalidates. (Response headers are readable for CORS/same-origin only.)
export class FreshThenStaleWhileRevalidate extends StaleWhileRevalidate {
  constructor({ freshForSeconds = 60, ...options } = {}) {
    super(options);
    this.freshForMs = freshForSeconds * 1000;
  }
  async _handle(request, handler) {
    // Note: super._handle() calls cacheMatch() again, so plugins with a
    // cachedResponseWillBeUsed callback (ExpirationPlugin) run twice on
    // the revalidating path. That is harmless for expiration bookkeeping.
    const cached = await handler.cacheMatch(request);
    const date = Date.parse(cached?.headers.get("Date") ?? "");
    if (cached && Date.now() - date < this.freshForMs) return cached;
    return super._handle(request, handler);
  }
}

Workbox's StaleWhileRevalidate._handle() starts handler.fetchAndCachePut(request) first, attaches a no-op .catch() and registers it with handler.waitUntil(), then looks in the cache. If there is a hit, it returns it; if not, it awaits the network promise, and if that fails too it throws no-response. Unlike the vanilla version, it has no in-flight deduplication and no freshness window: every request for a cached URL causes a network request. Its default cacheability, without a cacheWillUpdate plugin, is status 0 or 200.

When to use stale-while-revalidate

  • Unhashed CSS and JavaScript such as third-party widgets or a CMS theme file: fast from cache, corrected on the next load. Workbox's staticResourceCache recipe uses SWR for style, script and worker destinations.
  • Avatars, logos and other images that change rarely and where a stale copy is harmless.
  • API responses for reference data: category trees, feature flags, configuration, translation bundles.
  • HTML for content sites where speed beats freshness (documentation, blogs), ideally combined with update broadcasting so an open page can offer "This page has been updated. Reload?".
  • Cross-origin opaque resources where you can't check status codes: the next request replaces a bad entry, so an error self-heals after one view.

Stale-while-revalidate pitfalls

  • Every cache hit costs a background request. SWR never saves bandwidth, only latency. On metered connections that is a real cost; add a freshness window or honor navigator.connection.saveData in Chromium.
  • The HTTP cache can make revalidation a no-op. If the origin sends Cache-Control: max-age=3600, a plain fetch() in the worker gets the HTTP-cached copy for an hour and SWR "revalidates" against itself. Use cache: "no-cache" for the background fetch.
  • Stale by one request is unbounded in time. A monthly-visited page is a month stale. Use freshForMs, a maxAgeSeconds expiration (which turns an expired entry into a miss, so the user waits for the network), or network first for such pages.
  • The Date header measures the origin clock, not the device clock. Freshness windows computed from Date are off by the device's clock skew, and a CDN may serve a response whose Date is hours old (check Age). For precise windows, record your own timestamp when you write the entry, as the expiration helper does.
  • Deduplicating by URL ignores Vary. If a URL legitimately varies by a request header, key the in-flight map by URL plus that header.

Cache then network: the UI pattern

Cache then network is not a service worker strategy at all; it's a page-side pattern. The page makes two requests in parallel: one straight to Cache Storage (which windows can read through window.caches) and one to the network. It renders the cached data as soon as it arrives, then re-renders with the network data. The user sees content instantly and fresh content moments later. Jake Archibald's Offline Cookbook describes it for frequently updated content such as articles and social timelines.

How cache then network works

sequenceDiagram
    participant UI as Page code
    participant Cache as Cache Storage
    participant SW as Service worker
    participant Net as Network
    par read cache directly
        UI->>Cache: caches.open then match
        Cache-->>UI: cached JSON
        UI->>UI: render cached data, mark as stale
    and ask the network
        UI->>SW: fetch /api/feed
        SW->>Net: fetch(request)
        Net-->>SW: 200 OK
        SW->>Cache: put clone
        SW-->>UI: fresh JSON
        UI->>UI: render fresh data
    end

The service worker's job on this route is "network, and store what you get". It must not fall back to the cache on failure, because the page already rendered the cached copy; falling back would just make the page render the same data twice and hide the fact that it's offline.

Implementing cache then network

src/cache-then-network.js
/**
 * Render cached data immediately, then fresh data from the network.
 * @param {string} url  Same URL string the service worker caches under.
 * @param {(data: unknown, meta: { source: "cache" | "network" }) => void} render
 * @returns {Promise<"network" | "cache" | "none">} which source ended up on screen
 */
export async function cacheThenNetwork(url, render, { cacheName = "api", signal } = {}) {
  let networkRendered = false;

  const network = fetch(url, { headers: { Accept: "application/json" }, signal })
    .then(async (response) => {
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const data = await response.json();
      networkRendered = true;
      render(data, { source: "network" });
    });

  const cached = (async () => {
    if (!("caches" in window)) return false; // insecure context or old browser
    const cache = await caches.open(cacheName);
    const response = await cache.match(url);
    if (!response) return false;
    const data = await response.json();
    // The network may have won while we were parsing: never overwrite
    // fresh data with stale data.
    if (networkRendered || signal?.aborted) return false;
    render(data, { source: "cache" });
    return true;
  })().catch(() => false); // a corrupt entry must not break the network path

  try {
    await network;
    return "network";
  } catch (error) {
    if (error.name === "AbortError") throw error;
    // Offline or failing: whatever the cache gave us is all there is.
    return (await cached) ? "cache" : "none";
  }
}
sw/strategies/network-and-cache.js
import { fetchAndCache } from "../lib/cache-helpers.js";

/** Service worker half: network only, but keep a copy for the page to read. */
export function networkAndCache(event, { cacheName }) {
  return fetchAndCache(event, event.request, cacheName);
}
sw.js
import { registerRoute } from "workbox-routing";
import { Strategy } from "workbox-strategies";

// "Network, and store what you get": fetchAndCachePut() fetches, runs
// plugins, and writes the clone inside the handler's waitUntil().
class NetworkAndCache extends Strategy {
  _handle(request, handler) {
    return handler.fetchAndCachePut(request);
  }
}

registerRoute(
  ({ url, sameOrigin }) => sameOrigin && url.pathname === "/api/feed",
  new NetworkAndCache({ cacheName: "api" }),
);

// The page-side cacheThenNetwork() helper is identical: Workbox has no
// window-side equivalent, and none is needed.
src/feed.js
import { cacheThenNetwork } from "./cache-then-network.js";

const controller = new AbortController();
const list = document.querySelector("#feed");
const banner = document.querySelector("#offline-banner");

const source = await cacheThenNetwork("/api/feed", (items, { source }) => {
  list.replaceChildren(...items.map(renderItem));
  list.toggleAttribute("aria-busy", source === "cache"); // still refreshing
}, { signal: controller.signal });

banner.hidden = source === "network";
if (source === "none") list.textContent = "You're offline and nothing is saved yet.";

When to use cache then network

  • Feeds, timelines, inboxes and dashboards, where showing something immediately and updating in place is the expected UX.
  • Single-page app views that own their rendering and can reconcile two renders without layout jumps.
  • Data that must be both instant and fresh, when network first's timeout would still feel slow.

Cache then network pitfalls

  • Double rendering has UX cost. Content that jumps when fresh data arrives causes layout shifts (CLS) and can move the element under the user's finger. Reconcile by key, keep scroll position, and consider showing "3 new items" instead of re-rendering.
  • The cache key must match exactly. The page reads caches.match("/api/feed") with a plain URL; if the service worker stored the entry under a Request whose response has Vary: Accept or Vary: Authorization, the lookup misses. Keep API responses free of unnecessary Vary, or pass { ignoreVary: true } knowing what it means.
  • It doesn't work without a controlling worker writing the cache, and it needs a secure context for window.caches. On the very first visit there is nothing to render from cache.
  • Race conditions are real. The network can win; the cached branch must check whether fresh data is already on screen, as the helper does.

Race: cache and network, fastest wins

The race strategy sends the request to both the cache and the network at the same time and responds with whichever produces a usable response first. The cookbook motivates it with devices where storage is slow: with some combinations of older hard drives, virus scanners and fast internet connections, fetching from the network can be quicker than reading from disk. It is also a way to hedge against cache lookups that are slow because the cache is huge.

How the race works

sequenceDiagram
    participant Page
    participant SW as Service worker
    participant Cache as Cache Storage
    participant Net as Network
    Page->>SW: GET /img/thumb/881.webp
    par cache lookup
        SW->>Cache: match(request)
    and network request
        SW->>Net: fetch(request)
    end
    Cache-->>SW: hit after 40 ms
    SW-->>Page: cached response wins
    Net-->>SW: 200 after 180 ms
    SW->>Cache: put clone, refreshed for next time

Implementing the race

The textbook version, Promise.race([caches.match(request), fetch(request)]), has two bugs. Promise.race() settles with the first promise to settle, including a rejection, so an instant offline TypeError beats a cache hit. And caches.match() resolves with undefined on a miss, so a fast miss "wins" and respondWith() receives undefined, which is a network error. Use Promise.any() (Chrome 85, Firefox 79, Safari 14), turn a miss into a rejection, and decide whether an HTTP error status counts as a win.

sw/strategies/race.js
import { fetchAndCache } from "../lib/cache-helpers.js";

/**
 * Race cache and network; the first *usable* response wins.
 * A 5xx or 4xx from the network doesn't beat a cache hit.
 */
export async function raceCacheAndNetwork(event, { cacheName, allowOpaque = false } = {}) {
  const { request } = event;
  // Starts immediately and registers its cache write with waitUntil().
  const network = fetchAndCache(event, request, cacheName, { allowOpaque });

  const usableNetwork = network.then((response) =>
    response.ok || response.type === "opaque" ? response : Promise.reject(response),
  );
  const cacheHit = caches
    .open(cacheName)
    .then((cache) => cache.match(request))
    .then((hit) => hit ?? Promise.reject(new Error("cache miss")));

  try {
    return await Promise.any([cacheHit, usableNetwork]);
  } catch {
    // Both lost. Prefer a real HTTP error response over a network error:
    // if the network answered 404, the page should see the 404.
    return network; // rejects with the fetch TypeError when offline
  }
}
sw.js
import { registerRoute } from "workbox-routing";
import { Strategy } from "workbox-strategies";

// Adapted from the CacheNetworkRace example in the Workbox docs, with a
// miss/HTTP-error aware winner selection.
class CacheNetworkRace extends Strategy {
  _handle(request, handler) {
    const network = handler.fetchAndCachePut(request);
    const usableNetwork = network.then((r) =>
      r.ok || r.type === "opaque" ? r : Promise.reject(r),
    );
    const cacheHit = handler
      .cacheMatch(request)
      .then((hit) => hit ?? Promise.reject(new Error("cache miss")));
    return Promise.any([cacheHit, usableNetwork]).catch(() => network);
  }
}

registerRoute(
  ({ request, sameOrigin }) => sameOrigin && request.destination === "image",
  new CacheNetworkRace({ cacheName: "images" }),
);

When to use the race

  • Small, frequently used assets on devices where you have evidence (from RUM data) that cache reads are slow.
  • As a hedge for enormous caches, although fixing the cache size with expiration is usually the better fix.

Browsers are experimenting with racing at the platform level too. The Static Routing API includes a "race-network-and-fetch-handler" source that races a network request against your fetch handler without waiting for the worker to boot, and Chrome's ServiceWorkerAutoPreload optimization issues the network request in parallel with worker startup. Both address service worker startup latency rather than slow disks.

Race pitfalls

  • The network request is always made, even when the cache wins, so the race never saves bandwidth; it only saves latency. Don't race large resources or anything on a metered connection.
  • Opaque responses can't be judged. ok is false and status is 0 for opaque responses, so the helper lets them win unconditionally; with allowOpaque off they are still returned but never cached.
  • Responses are not interchangeable. If the cached and network versions differ (a new deploy), consecutive loads can alternate between versions depending on which source wins. Only race immutable or version-agnostic resources.

Generic fallback

A generic fallback is what the router returns when the chosen strategy fails completely: no network and nothing in the cache. Without one, respondWith() receives a rejected promise and the browser shows its own error page, or the page's fetch() rejects with a bare TypeError. A fallback turns those into something your UI controls. The full UX design of offline states lives in Offline UX & Fallbacks; this section covers the mechanics.

How the generic fallback works

sequenceDiagram
    participant Page
    participant SW as Service worker
    participant Net as Network
    participant Cache as Cache Storage
    Page->>SW: navigate to /never-visited
    SW->>Net: network first
    Net--xSW: TypeError, offline
    SW->>Cache: match /never-visited
    Cache-->>SW: undefined
    SW->>Cache: match /offline.html in the precache
    Cache-->>SW: offline page
    SW-->>Page: offline page, URL bar still shows /never-visited

Implementing the generic fallback

The fallback responses must be precached at install time, so they're guaranteed to exist exactly when the network isn't there to fetch them. Select the fallback by request.destination ("document" for navigations, "image", "font", "style", "script", and the empty string for fetch() and XHR calls), not by URL extension.

sw/strategies/fallback.js
import { CACHES } from "../lib/cache-helpers.js";

export const FALLBACK_URLS = ["/offline.html", "/img/offline.svg"];

// Precache fallbacks as an install dependency: if they can't be stored,
// installation fails and the previous worker stays in control.
export function precacheFallbacks(installEvent) {
  installEvent.waitUntil(
    caches.open(CACHES.shell).then((cache) => cache.addAll(FALLBACK_URLS)),
  );
}

/** Wrap any strategy so that a total failure produces a controlled response. */
export function withFallback(strategy) {
  return async (event, options) => {
    try {
      return await strategy(event, options);
    } catch (error) {
      return offlineFallback(event.request, error);
    }
  };
}

export async function offlineFallback(request, error) {
  const cache = await caches.open(CACHES.shell);
  switch (request.destination) {
    case "document":
      // Navigations: the precached offline page. Response.error() only if
      // even that is missing (should be impossible after a good install).
      return (await cache.match("/offline.html")) ?? Response.error();
    case "image":
      return (await cache.match("/img/offline.svg")) ??
        new Response(
          '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>',
          { headers: { "Content-Type": "image/svg+xml" } },
        );
    case "": {
      // fetch()/XHR. Only synthesize JSON for callers that asked for it.
      if (request.headers.get("Accept")?.includes("application/json")) {
        return new Response(
          JSON.stringify({ error: "offline", message: String(error?.message ?? error) }),
          {
            status: 503,
            statusText: "Service Unavailable (offline)",
            headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
          },
        );
      }
      return Response.error();
    }
    default:
      // Fonts, scripts, styles: a network error lets the browser apply its
      // own fallback (font-display, onerror handlers, noscript paths).
      return Response.error();
  }
}
sw.js
import { setCatchHandler, setDefaultHandler } from "workbox-routing";
import { NetworkOnly } from "workbox-strategies";
import { matchPrecache, precacheAndRoute } from "workbox-precaching";

// offline.html and offline.svg must be in the precache manifest.
precacheAndRoute(self.__WB_MANIFEST);
setDefaultHandler(new NetworkOnly());

// Called whenever a route's handler throws (including "no-response").
setCatchHandler(async ({ request }) => {
  switch (request.destination) {
    case "document":
      return (await matchPrecache("/offline.html")) ?? Response.error();
    case "image":
      return (await matchPrecache("/img/offline.svg")) ?? Response.error();
    default:
      return Response.error();
  }
});

// Alternatives: the offlineFallback() recipe from workbox-recipes, or
// PrecacheFallbackPlugin({ fallbackURL }) from workbox-precaching on a
// single strategy (it hooks the handlerDidError plugin callback).

When to use a generic fallback

Always, as the last layer of every route that can fail. The only question is what to return for each destination:

Destination Recommended fallback Why
document Precached offline page, or a cached app shell for SPAs Replaces the browser's dinosaur/error page
image Precached placeholder SVG sized by CSS Keeps layout stable and signals "unavailable"
"" (fetch()) returning JSON Synthetic 503 JSON with Cache-Control: no-store Lets app code branch on response.status instead of catching a TypeError
font Response.error() font-display already provides the fallback font
script, style Response.error() A fake script or stylesheet is worse than a failed one
video, audio Response.error() Media elements fire error, which your player UI can handle

Generic fallback pitfalls

  • Fallbacks outside the precache don't exist when you need them. Never fetch the offline page lazily.
  • Don't cache a fallback under the original URL. If the synthetic 503 or the offline page is written into the runtime cache for /articles/42, that URL is broken until evicted. Fallback responses are generated or read from the precache, never stored.
  • Keep the offline page self-contained. It is served under whatever URL the user navigated to, so relative URLs inside it resolve against that URL. Inline its CSS and images or reference precached absolute URLs.
  • Response.error() is not "no response". It resolves respondWith() with a network error response. Use it deliberately when you want the browser's native failure behavior.

Cache with expiration

Cache Storage has no expiration mechanism of any kind, so "cache with expiration" is a policy layered on top of another strategy: cache first, network first or stale-while-revalidate, plus rules that delete entries when a cache holds more than maxEntries items or when an entry is older than maxAgeSeconds. Without it, a runtime image cache on a busy site grows until the browser's quota evicts all of your origin's storage, as described in Storage Quotas & Persistence.

How expiration works

sequenceDiagram
    participant SW as Service worker
    participant Cache as Cache Storage
    participant IDB as IndexedDB metadata
    SW->>Cache: match(request)
    Cache-->>SW: cached response
    SW->>IDB: read cachedAt for URL
    alt older than maxAge
        SW->>SW: treat as a miss, fetch from network
    else fresh
        SW->>IDB: update usedAt, in waitUntil
    end
    SW->>Cache: put(request, response) on refresh
    SW->>IDB: write cachedAt and usedAt
    SW->>IDB: cursor over entries, newest usedAt first
    IDB-->>SW: URLs beyond maxEntries or past maxAge
    SW->>Cache: delete(url, ignoreVary true)

The metadata has to live somewhere other than the cached Response, for three reasons. Responses from cache.match() have immutable headers, so you can't stamp a timestamp onto them in place. Opaque responses have no readable headers and can't be re-wrapped (a Response can't be constructed with status 0). And cache.keys() has no access-time information: the spec returns entries in insertion order, with a put() of an existing URL moving it to the end, which gives you "least recently written", not "least recently used". IndexedDB is the natural home, and it's what Workbox's ExpirationPlugin uses.

Implementing cache with expiration

sw/lib/expiration.js
// Per-entry metadata for Cache Storage: when an entry was written (for
// maxAge) and when it was last served (for LRU eviction by maxEntries).
const DB_NAME = "sw-cache-expiration";
const STORE = "entries";
let dbPromise;

function openDB() {
  dbPromise ??= new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, 1);
    req.onupgradeneeded = () => {
      const store = req.result.createObjectStore(STORE, { keyPath: "id" });
      store.createIndex("byCacheAndUse", ["cacheName", "usedAt"]);
    };
    req.onsuccess = () => {
      const db = req.result;
      db.onversionchange = () => {
        db.close(); // never block a future upgrade
        dbPromise = undefined; // reopen lazily on the next call
      };
      resolve(db);
    };
    req.onerror = () => reject(req.error);
  }).catch((error) => {
    dbPromise = undefined; // allow a retry on the next call
    throw error;
  });
  return dbPromise;
}

function run(mode, work) {
  return openDB().then((db) => new Promise((resolve, reject) => {
    const tx = db.transaction(STORE, mode);
    const result = work(tx.objectStore(STORE));
    tx.oncomplete = () => resolve(result.value);
    tx.onerror = () => reject(tx.error);
    tx.onabort = () => reject(tx.error);
  }));
}

const idFor = (cacheName, url) => `${cacheName}|${url}`;

export function getEntry(cacheName, url) {
  return run("readonly", (store) => {
    const out = {};
    store.get(idFor(cacheName, url)).onsuccess = (e) => { out.value = e.target.result; };
    return out;
  });
}

/** Record a write (resets age) or a read (refreshes LRU position). */
export function touch(cacheName, url, { written = false } = {}) {
  return run("readwrite", (store) => {
    const id = idFor(cacheName, url);
    const now = Date.now();
    store.get(id).onsuccess = (e) => {
      const prev = e.target.result;
      store.put({
        id, cacheName, url,
        cachedAt: written || !prev ? now : prev.cachedAt,
        usedAt: now,
      });
    };
    return {};
  });
}

/**
 * Delete entries past maxAgeSeconds (by write time) or beyond maxEntries
 * (least recently used first). Returns the deleted URLs.
 */
export async function expire(cacheName, { maxEntries, maxAgeSeconds } = {}) {
  const oldest = maxAgeSeconds ? Date.now() - maxAgeSeconds * 1000 : -Infinity;
  const doomed = await run("readwrite", (store) => {
    const out = { value: [] };
    const range = IDBKeyRange.bound([cacheName, -Infinity], [cacheName, Infinity]);
    let kept = 0;
    // "prev" walks from most to least recently used.
    store.index("byCacheAndUse").openCursor(range, "prev").onsuccess = (e) => {
      const cursor = e.target.result;
      if (!cursor) return;
      const { url, cachedAt } = cursor.value;
      if (cachedAt < oldest || (maxEntries && kept >= maxEntries)) {
        out.value.push(url);
        cursor.delete();
      } else {
        kept += 1;
      }
      cursor.continue();
    };
    return out;
  });
  if (doomed.length) {
    const cache = await caches.open(cacheName);
    // ignoreVary: a plain URL has none of the headers a Vary'd entry was
    // stored with, and would otherwise fail to match (and to delete).
    await Promise.all(doomed.map((url) => cache.delete(url, { ignoreVary: true })));
  }
  return doomed;
}

/** Drop all metadata for a cache you are deleting. */
export async function forgetCache(cacheName) {
  const range = IDBKeyRange.bound([cacheName, -Infinity], [cacheName, Infinity]);
  await run("readwrite", (store) => {
    store.index("byCacheAndUse").openCursor(range).onsuccess = (e) => {
      const cursor = e.target.result;
      if (cursor) { cursor.delete(); cursor.continue(); }
    };
    return {};
  });
}
sw/strategies/cache-first-expiring.js
import { fromNetwork, isCacheable, putInCache } from "../lib/cache-helpers.js";
import { expire, getEntry, touch } from "../lib/expiration.js";

/**
 * Cache first with maxEntries/maxAgeSeconds. Unlike a plain cache first,
 * an expired entry is a miss, but it's still served if the network fails
 * (the stale-if-error behavior of RFC 5861).
 */
export async function cacheFirstExpiring(event, {
  cacheName, maxEntries, maxAgeSeconds, allowOpaque = false,
}) {
  const { request } = event;
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);
  const limits = { maxEntries, maxAgeSeconds };

  if (cached) {
    const meta = await getEntry(cacheName, request.url).catch(() => undefined);
    const expired = maxAgeSeconds && meta && Date.now() - meta.cachedAt > maxAgeSeconds * 1000;
    if (!expired) {
      event.waitUntil(touch(cacheName, request.url).catch(() => {}));
      return cached;
    }
  }

  const network = fromNetwork(event, request);
  event.waitUntil((async () => {
    try {
      const response = await network;
      if (!isCacheable(response, { allowOpaque })) return;
      if (await putInCache(cacheName, request, response.clone())) {
        await touch(cacheName, request.url, { written: true });
        await expire(cacheName, limits);
      }
    } catch {
      // Network failure: handled on the response path below.
    }
  })());

  try {
    return await network;
  } catch (error) {
    if (cached) return cached; // expired, but better than nothing offline
    throw error;
  }
}
sw.js
import { registerRoute } from "workbox-routing";
import { CacheFirst } from "workbox-strategies";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
import { ExpirationPlugin } from "workbox-expiration";

registerRoute(
  ({ request, sameOrigin }) => sameOrigin && request.destination === "image",
  new CacheFirst({
    cacheName: "images",
    plugins: [
      new CacheableResponsePlugin({ statuses: [200] }),
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 30 * 24 * 60 * 60,
        purgeOnQuotaError: true, // delete this cache if any write hits the quota
        matchOptions: { ignoreVary: true }, // used when deleting expired URLs
      }),
    ],
  }),
);

How Workbox's ExpirationPlugin actually expires entries

Knowing the implementation explains behavior that otherwise looks like a bug:

  • Timestamps live in IndexedDB, one timestamp per URL per cache. The plugin updates it in cacheDidUpdate (after a write, then runs expireEntries()) and in cachedResponseWillBeUsed (on every cache read). Because reads refresh the timestamp, maxEntries evicts the least recently used entries.
  • The same timestamp drives maxAgeSeconds. Since reads refresh it, the IndexedDB check effectively measures time since last use. For responses with a readable Date header, the plugin also does a cheap freshness check at read time: if Date is older than maxAgeSeconds, cachedResponseWillBeUsed returns null and the strategy treats it as a miss. Opaque responses have no readable Date, so for them only the refreshed-on-read timestamp applies, and a frequently used opaque entry never ages out by maxAgeSeconds.
  • Expiration is lazy. It runs after requests and cache updates, never on a timer. The documentation spells out the consequence: an expired entry may be used once before it is removed, unless the Date check catches it.
  • purgeOnQuotaError: true registers a callback that deletes the whole cache (and its metadata) when any Workbox cache write throws QuotaExceededError. Use it on caches you can afford to lose.
  • It refuses the default runtime cache. Using ExpirationPlugin on a strategy without a custom cacheName throws expire-custom-caches-only.
  • matchOptions is used for deletion. If your responses carry Vary, set { ignoreVary: true } or expired entries may not be deleted.

Choosing expiration limits

Cache maxEntries maxAgeSeconds Reasoning
Content-hashed JS/CSS 100–200 1 year Old hashes pile up across deploys; count limits evict them
Images 50–200 30 days Workbox's imageCache recipe defaults to 60 entries and 30 days
Fonts 30 1 year The googleFontsCache recipe defaults to 30 entries and 1 year
HTML pages 25–50 1–7 days Offline reading of recent pages; old pages are rarely wanted
API JSON 50–100 minutes to days Depends entirely on how stale the data may be
Third-party opaque 20–50 7 days Padded quota cost makes each entry expensive

Expiration pitfalls

  • maxAgeSeconds without maxEntries doesn't bound size. A thousand images downloaded today are all "fresh" today.
  • Deleting a cache doesn't delete its metadata. If you caches.delete() a runtime cache on activation, clear its IndexedDB records too (forgetCache() above), or expire() will try to delete URLs that no longer exist (harmless) and getEntry() may report stale ages for new entries (harmful).
  • IndexedDB can fail. Private modes and storage pressure can make it throw. Treat metadata failures as "unknown age" and keep serving; never let expiration bookkeeping break the response path.

Mapping strategies to resource types

Strategies are assigned per class of request, and the class is determined by three properties you can read synchronously in the fetch handler: request.mode ("navigate" identifies top-level and iframe navigations), request.destination (what the browser will do with the response) and the URL (origin, path, and whether the filename carries a content hash). Requests made by fetch() and XHR have an empty destination, so API routes are matched by path, not destination.

Resource Match on Strategy Cache Limits Notes
HTML, dynamic (MPA) mode === "navigate" Network first, 2–4 s timeout, navigation preload pages ~50 entries, 1–7 days Offline page fallback
HTML, content site mode === "navigate" Stale-while-revalidate + update broadcast pages ~50 entries Offer "updated, reload?"
HTML, SPA mode === "navigate" Cache only (precached shell) shell-vN Precache Exclude /api/, /auth/ paths
Hashed CSS/JS Hash in filename Precache, or cache first shell-vN / immutable-assets ~200 entries Cache-Control: immutable too
Unhashed CSS/JS destination style/script/worker Stale-while-revalidate assets ~60 entries Or add hashes to the build
Images, same-origin destination === "image" Cache first + expiration images 60–200, 30 days purgeOnQuotaError
Images, cross-origin Other origin Stale-while-revalidate (opaque OK) third-party 20–50 entries Prefer CORS via crossorigin
Fonts destination === "font" Cache first fonts 30, 1 year Always CORS, so never opaque
API, reference data Path prefix Stale-while-revalidate with freshness window api 50 entries Broadcast updates
API, user data Path prefix Network first, 1–3 s timeout api 50 entries Purge on logout
API, feeds Path prefix Cache then network api 20 entries Page renders twice
API, live or writes Path, method Network only none none Background Sync for writes
Third-party scripts Origin Network only, or cache first if the URL pins a version third-party 20 entries Use SRI and CORS
Audio/video destination audio/video Cache only with Range slicing when downloaded, else network media User-managed Never runtime-cache partial content

HTML navigations

Navigations are the most important requests your service worker handles: they block everything else on the page, and a failure shows the browser's error screen. Three designs dominate:

  1. Multi-page apps with changing content use network first with a timeout, with navigation preload enabled so the network request starts while the worker boots. Cache every successful page so previously visited pages work offline, bound the cache by count, and fall back to the precached offline page.
  2. Content sites (docs, blogs) where speed matters more than minute-level freshness can use stale-while-revalidate for navigations and tell the page when a newer version arrived, as shown in Broadcasting updates.
  3. Single-page apps serve the same precached index.html for every in-app route (cache only), and let the client router render. In Workbox that's registerRoute(new NavigationRoute(createHandlerBoundToURL("/index.html"), { denylist: [/^\/api\//, /^\/auth\//] })). The denylist matters: without it, a navigation to /auth/callback?code=... or a direct link to a JSON endpoint gets the app shell instead. See SPA vs MPA PWAs and the App Shell Model.

Navigation requests have redirect mode "manual", so when the worker fetches a URL that redirects, it gets back an opaqueredirect response (status 0) that the browser then follows. isCacheable() rejects those, which is correct: caching a redirect would pin users to the redirect target.

Content-hashed CSS and JavaScript

Build tools emit app.3f9a2c1d.js; the hash changes whenever the content does, so the URL is a permanent name for exactly one set of bytes. Precache the files the first render needs (see Precaching & Runtime Caching); serve everything else hashed with cache first. Don't use stale-while-revalidate for hashed files: revalidating an immutable URL wastes a request every time.

Two operational details: keep old hashed files on your server or CDN for a while after each deploy, because pages still running the previous version will request them; and bound the runtime cache by count, because every deploy adds a new generation of files and none of the old ones are ever requested again.

Images

Same-origin images are the classic cache-first-with-expiration workload: large, numerous, rarely changed in place. Cross-origin images loaded without crossorigin are opaque, which makes cache first risky (errors become permanent) and expensive (quota padding). For those, either switch the markup to <img crossorigin="anonymous"> and have the image host send Access-Control-Allow-Origin, or use stale-while-revalidate with a small maxEntries. Responsive images (srcset) generate one cache entry per candidate the browser picks, so resizing or rotating the device can add a second copy of every image.

Fonts

Font requests are always made in CORS mode, even same-origin, which makes the response readable and cacheable with a real status check. Font files are almost always versioned, so cache first with a one-year expiration is standard. Google Fonts splits into two origins with different needs: the CSS from fonts.googleapis.com varies by user agent and changes when the font family updates, so it gets stale-while-revalidate; the font files from fonts.gstatic.com are immutable and get cache first. That's precisely what Workbox's googleFontsCache recipe registers (30 entries and a one-year maxAgeSeconds by default for the font files). For performance, self-hosting usually beats both.

API JSON

API responses need the most per-endpoint thought, because "how stale is acceptable" differs for every endpoint:

  • Reference data (categories, configuration, translations): stale-while-revalidate with a freshness window of a minute to an hour, plus update broadcasting so an open screen re-renders when data changes.
  • User-specific data (profile, orders, settings): network first with a short timeout. Purge on logout.
  • Feeds and lists: cache then network in the page, with the worker in "network and store" mode.
  • Live data and writes: network only. Offline writes go into an outbox and are replayed with Background Sync; richer offline data models belong in IndexedDB with an explicit sync layer, as covered in Offline-First Data & Sync.

Cache Storage is a good fit for API responses you want to replay byte-for-byte. It's a poor fit for data you need to query, merge or modify locally; parse such responses into IndexedDB instead.

Third-party resources

Third-party requests split into three groups. Analytics, tag managers, chat widgets and A/B testing scripts should go network only: they are unversioned, they expect to be fresh, and a cached copy can pin users to outdated configuration. Versioned CDN libraries (a URL that pins an exact version) can be cache first, but only with crossorigin="anonymous" so the response is CORS and not opaque, and with Subresource Integrity so the browser refuses a tampered file whether it arrives from the CDN or from your cache: the integrity check runs on whatever response the service worker supplies (see also Content Security Policy). Everything else gets stale-while-revalidate in a small, separately purgeable cache. Remember that a cross-origin request only reaches your service worker if it's made by a page you control; requests from third-party iframes go to their origin's worker, if any.

Audio, video and Range requests

Media elements don't download a file in one request. They send Range requests (Range: bytes=0-, then ranges around the playback position when the user seeks), and servers answer with 206 Partial Content. Three facts make this hard to cache:

  • Cache.put() rejects 206 responses outright, so partial content can't be runtime-cached.
  • Safari requires byte-range support for media. Apple's Safari documentation states that HTTP servers hosting media files for iOS must support byte-range requests. Answering a Range request with a full 200 from the cache may work in some browsers but breaks seeking and can break playback in Safari.
  • Cross-origin media without the crossorigin attribute is opaque, and an opaque body can't be sliced.

The working pattern: download the complete file deliberately (a "Save for offline" button calling cache.add(url), which requests the file without a Range header and gets a cacheable 200; or Background Fetch for large files), then answer later Range requests by slicing the cached body. Anything not downloaded streams from the network untouched.

sw/strategies/range.js
/**
 * Serve media from a complete cached copy, honoring Range requests
 * (RFC 9110, section 14). Not downloaded? Stream from the network.
 */
export async function rangeFromCache(event, { cacheName }) {
  const { request } = event;
  const cache = await caches.open(cacheName);
  // Look up by URL: the entry was stored without a Range header, and the
  // media request's own headers must not influence the match.
  const full = await cache.match(request.url, { ignoreVary: true });
  if (!full) return fetch(request); // untouched: the network answers with 206s
  const range = request.headers.get("Range");
  return range ? sliceResponse(full, range) : full;
}

export async function sliceResponse(full, rangeHeader) {
  // Browsers may back large blobs with disk, but don't assume it: for
  // multi-gigabyte media, consider storing files in OPFS instead.
  const blob = await full.blob();
  const size = blob.size;
  const headers = new Headers(full.headers);
  headers.set("Accept-Ranges", "bytes");

  const m = /^bytes=(\d*)-(\d*)$/i.exec(rangeHeader.trim());
  if (!m || (m[1] === "" && m[2] === "")) {
    // Multiple ranges or an unparseable header: ignoring Range and
    // sending the full 200 is always a valid answer.
    return new Response(blob, { status: 200, headers });
  }

  let start;
  let end; // inclusive byte positions
  if (m[1] === "") {
    // Suffix range "bytes=-500": the last 500 bytes.
    const suffix = Number(m[2]);
    if (suffix === 0) return unsatisfiable(size);
    start = Math.max(0, size - suffix);
    end = size - 1;
  } else {
    start = Number(m[1]);
    // A last-pos beyond the end is clamped, not an error.
    end = m[2] === "" ? size - 1 : Math.min(Number(m[2]), size - 1);
    if (m[2] !== "" && Number(m[2]) < start) {
      return new Response(blob, { status: 200, headers }); // invalid: ignore Range
    }
  }
  if (start >= size) return unsatisfiable(size);

  headers.set("Content-Range", `bytes ${start}-${end}/${size}`);
  headers.set("Content-Length", String(end - start + 1));
  return new Response(blob.slice(start, end + 1), {
    status: 206,
    statusText: "Partial Content",
    headers,
  });
}

function unsatisfiable(size) {
  return new Response(null, {
    status: 416,
    statusText: "Range Not Satisfiable",
    headers: { "Content-Range": `bytes */${size}` },
  });
}
src/save-offline.js
// Page side: an explicit, user-initiated download into the media cache.
export async function saveForOffline(url) {
  const cache = await caches.open("media");
  await cache.add(url); // rejects on non-2xx; no Range header, so a full 200
}
sw.js
import { registerRoute } from "workbox-routing";
import { CacheFirst } from "workbox-strategies";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
import { RangeRequestsPlugin } from "workbox-range-requests";

registerRoute(
  ({ request }) => request.destination === "video" || request.destination === "audio",
  new CacheFirst({
    cacheName: "media",
    plugins: [
      // A miss goes to the network, which answers 206; statuses [200]
      // keeps that partial response out of the cache.
      new CacheableResponsePlugin({ statuses: [200] }),
      // On a hit, slices the cached 200 into a 206 for the Range header.
      new RangeRequestsPlugin(),
    ],
  }),
);

RangeRequestsPlugin works in the cachedResponseWillBeUsed callback: if the request has a Range header and there is a cached response, it builds a 206 from a Blob slice (passing through a cached 206 untouched) and returns a 416 if it can't parse or satisfy the range. It supports only a single range per request, like the vanilla version above.

A complete production router

The router below combines every strategy on this page into one module service worker. Routes are evaluated in order and the first match wins, the same rule Workbox's Router uses. Match functions are synchronous on purpose: the decision whether to call respondWith() has to be made during event dispatch. A route whose handler is null is a passthrough, which means the listener returns without calling respondWith() and the browser handles the request natively.

sw.js
// Register with: navigator.serviceWorker.register("/sw.js", { type: "module" })
import { CACHES } from "./sw/lib/cache-helpers.js";
import { forgetCache } from "./sw/lib/expiration.js";
import { notifyIfUpdated } from "./sw/lib/broadcast.js";
import { cacheOnly } from "./sw/strategies/cache-only.js";
import { cacheFirst } from "./sw/strategies/cache-first.js";
import { cacheFirstExpiring } from "./sw/strategies/cache-first-expiring.js";
import { networkFirst } from "./sw/strategies/network-first.js";
import { networkAndCache } from "./sw/strategies/network-and-cache.js";
import { staleWhileRevalidate } from "./sw/strategies/stale-while-revalidate.js";
import { rangeFromCache } from "./sw/strategies/range.js";
import { FALLBACK_URLS, offlineFallback } from "./sw/strategies/fallback.js";

const DAY = 24 * 60 * 60;

// In a real build this list is generated (see the precaching page), with
// content-hashed names, so a new deploy changes this file byte-for-byte
// and triggers a service worker update.
const PRECACHE_URLS = [
  ...FALLBACK_URLS,          // /offline.html, /img/offline.svg
  "/assets/app.3f9a2c1d.js",
  "/assets/app.8e21b4f0.css",
];
const PRECACHED_PATHS = new Set(PRECACHE_URLS);
const HASHED_ASSET = /\.[0-9a-f]{8,}\.(?:m?js|css)$/;
const CDN_ORIGIN = "https://cdn.jsdelivr.net";

// ---------------------------------------------------------------- install
self.addEventListener("install", (event) => {
  // addAll() is atomic: if any URL fails, nothing is stored and the
  // install fails, leaving the previous worker in control.
  event.waitUntil(caches.open(CACHES.shell).then((cache) => cache.addAll(PRECACHE_URLS)));
});

// --------------------------------------------------------------- activate
self.addEventListener("activate", (event) => {
  event.waitUntil((async () => {
    // Delete previous precache generations only: other caches are
    // runtime caches shared across versions, with their own limits.
    const names = await caches.keys();
    await Promise.all(
      names
        .filter((name) => name.startsWith("shell-") && name !== CACHES.shell)
        .map(async (name) => {
          await caches.delete(name);
          await forgetCache(name).catch(() => {});
        }),
    );
    // Start navigation requests in parallel with worker boot.
    await self.registration.navigationPreload?.enable();
  })());
});

// ----------------------------------------------------------------- routes
/**
 * @typedef {{ request: Request, url: URL, sameOrigin: boolean }} RouteContext
 * @typedef {{ name: string, match: (ctx: RouteContext) => boolean,
 *   handle: ((event: FetchEvent) => Promise<Response>) | null,
 *   trim?: { cacheName: string, maxEntries: number } }} Route
 * @type {Route[]}
 */
const routes = [
  {
    name: "passthrough: non-GET",
    match: ({ request }) => request.method !== "GET",
    handle: null,
  },
  {
    name: "passthrough: auth, live data, analytics",
    match: ({ url, sameOrigin }) =>
      (sameOrigin && /^\/(auth|api\/live)\//.test(url.pathname)) ||
      url.hostname === "analytics.example.com",
    handle: null,
  },
  {
    name: "media: Range slicing for downloaded files",
    match: ({ request }) => request.destination === "video" || request.destination === "audio",
    handle: (event) => rangeFromCache(event, { cacheName: CACHES.media }),
  },
  {
    name: "precache: cache only",
    match: ({ url, sameOrigin }) => sameOrigin && PRECACHED_PATHS.has(url.pathname),
    handle: (event) => cacheOnly(event, { cacheName: CACHES.shell }),
  },
  {
    name: "navigations: network first, 3 s",
    match: ({ request }) => request.mode === "navigate",
    handle: (event) => networkFirst(event, { cacheName: CACHES.pages, timeoutMs: 3000 }),
    trim: { cacheName: CACHES.pages, maxEntries: 50 },
  },
  {
    name: "hashed assets: cache first",
    match: ({ url, sameOrigin }) => sameOrigin && HASHED_ASSET.test(url.pathname),
    handle: (event) => cacheFirstExpiring(event, {
      cacheName: CACHES.immutable, maxEntries: 200, maxAgeSeconds: 365 * DAY,
    }),
  },
  {
    name: "unhashed scripts and styles: SWR",
    match: ({ request, sameOrigin }) =>
      sameOrigin && ["script", "style", "worker"].includes(request.destination),
    handle: (event) => staleWhileRevalidate(event, { cacheName: CACHES.assets, allowOpaque: false }),
    trim: { cacheName: CACHES.assets, maxEntries: 60 },
  },
  {
    name: "fonts: cache first",
    match: ({ request }) => request.destination === "font",
    handle: (event) => cacheFirstExpiring(event, {
      cacheName: CACHES.fonts, maxEntries: 30, maxAgeSeconds: 365 * DAY,
    }),
  },
  {
    name: "images, same-origin: cache first + expiration",
    match: ({ request, sameOrigin }) => sameOrigin && request.destination === "image",
    handle: (event) => cacheFirstExpiring(event, {
      cacheName: CACHES.images, maxEntries: 150, maxAgeSeconds: 30 * DAY,
    }),
  },
  {
    name: "versioned CDN libraries: cache first (CORS only)",
    match: ({ url }) => url.origin === CDN_ORIGIN && /@\d+\.\d+\.\d+\//.test(url.pathname),
    handle: (event) => cacheFirst(event, { cacheName: CACHES.thirdParty, allowOpaque: false }),
    trim: { cacheName: CACHES.thirdParty, maxEntries: 40 },
  },
  {
    name: "third-party images: SWR, opaque allowed",
    match: ({ request, sameOrigin }) => !sameOrigin && request.destination === "image",
    handle: (event) => staleWhileRevalidate(event, { cacheName: CACHES.thirdParty, allowOpaque: true }),
    trim: { cacheName: CACHES.thirdParty, maxEntries: 40 },
  },
  {
    name: "API reference data: SWR + freshness + broadcast",
    match: ({ url, sameOrigin }) => sameOrigin && url.pathname.startsWith("/api/catalog/"),
    handle: (event) => staleWhileRevalidate(event, {
      cacheName: CACHES.api, freshForMs: 60_000, allowOpaque: false, onUpdate: notifyIfUpdated,
    }),
    trim: { cacheName: CACHES.api, maxEntries: 80 },
  },
  {
    name: "API feed: network and store (page does cache then network)",
    match: ({ url, sameOrigin }) => sameOrigin && url.pathname === "/api/feed",
    handle: (event) => networkAndCache(event, { cacheName: CACHES.api }),
  },
  {
    name: "API everything else: network first, 3 s",
    match: ({ url, sameOrigin }) => sameOrigin && url.pathname.startsWith("/api/"),
    handle: (event) => networkFirst(event, { cacheName: CACHES.api, timeoutMs: 3000 }),
    trim: { cacheName: CACHES.api, maxEntries: 80 },
  },
  // No catch-all: unmatched requests are passthrough.
];

// ------------------------------------------------------------------ fetch
self.addEventListener("fetch", (event) => {
  const { request } = event;
  const url = new URL(request.url);
  if (url.protocol !== "https:" && url.protocol !== "http:") return; // extensions, data:
  const ctx = { request, url, sameOrigin: url.origin === self.location.origin };
  const route = routes.find((candidate) => candidate.match(ctx));
  if (!route?.handle) return; // passthrough: no respondWith, native handling

  event.respondWith(
    route.handle(event).catch((error) => {
      console.warn(`[sw] "${route.name}" failed for ${request.url}:`, error);
      return offlineFallback(request, error);
    }),
  );
  if (route.trim) event.waitUntil(maybeTrim(route.trim).catch(() => {}));
});

// -------------------------------------------------------- cache trimming
// Count-based trimming for caches without IndexedDB metadata. The spec
// defines keys() in insertion order, and put() of an existing URL moves
// it to the end, so the first keys are the least recently *written*.
const lastTrim = new Map();
async function maybeTrim({ cacheName, maxEntries }) {
  const now = Date.now();
  if (now - (lastTrim.get(cacheName) ?? 0) < 30_000) return; // at most every 30 s
  lastTrim.set(cacheName, now);
  const cache = await caches.open(cacheName);
  const keys = await cache.keys();
  const excess = keys.length - maxEntries;
  if (excess > 0) {
    await Promise.all(keys.slice(0, excess).map((key) => cache.delete(key, { ignoreVary: true })));
  }
}

// ---------------------------------------------------------------- logout
self.addEventListener("message", (event) => {
  if (event.data?.type !== "LOGOUT") return;
  // Personalized responses must not outlive the session that fetched them.
  event.waitUntil(
    Promise.all([CACHES.api, CACHES.pages].map(async (name) => {
      await caches.delete(name);
      await forgetCache(name).catch(() => {});
    })),
  );
});
sw.js
import { cleanupOutdatedCaches, matchPrecache, precacheAndRoute } from "workbox-precaching";
import { registerRoute, setCatchHandler } from "workbox-routing";
import {
  CacheFirst, NetworkFirst, StaleWhileRevalidate, Strategy,
} from "workbox-strategies";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
import { ExpirationPlugin } from "workbox-expiration";
import { BroadcastUpdatePlugin } from "workbox-broadcast-update";
import { RangeRequestsPlugin } from "workbox-range-requests";
import * as navigationPreload from "workbox-navigation-preload";

const DAY = 24 * 60 * 60;
const ok200 = new CacheableResponsePlugin({ statuses: [200] });
const expire = (maxEntries, maxAgeSeconds) =>
  new ExpirationPlugin({ maxEntries, maxAgeSeconds, purgeOnQuotaError: true });

// Live data and auth must bypass the /api/ routes below. This listener is
// added BEFORE the first Workbox call that creates the default router
// (precacheAndRoute, registerRoute, setCatchHandler), because that call
// adds Workbox's own fetch listener and listeners run in order.
self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);
  if (url.origin === location.origin && /^\/(auth|api\/live)\//.test(url.pathname)) {
    event.stopImmediatePropagation(); // no respondWith: native handling
  }
});

// Injected at build time by workbox-build / vite-plugin-pwa (injectManifest).
// Includes /offline.html, /img/offline.svg and the hashed app shell.
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
navigationPreload.enable();

// Non-GET requests never match: registerRoute() defaults to GET, and
// unmatched requests are left to the browser.

registerRoute(
  ({ request }) => request.destination === "video" || request.destination === "audio",
  new CacheFirst({ cacheName: "media", plugins: [ok200, new RangeRequestsPlugin()] }),
);

registerRoute(
  ({ request }) => request.mode === "navigate",
  new NetworkFirst({
    cacheName: "pages",
    networkTimeoutSeconds: 3,
    plugins: [ok200, expire(50, 7 * DAY)],
  }),
);

registerRoute(
  ({ url, sameOrigin }) => sameOrigin && /\.[0-9a-f]{8,}\.(?:m?js|css)$/.test(url.pathname),
  new CacheFirst({ cacheName: "immutable-assets", plugins: [ok200, expire(200, 365 * DAY)] }),
);

registerRoute(
  ({ request, sameOrigin }) =>
    sameOrigin && ["script", "style", "worker"].includes(request.destination),
  new StaleWhileRevalidate({ cacheName: "assets", plugins: [ok200, expire(60)] }),
);

registerRoute(
  ({ request }) => request.destination === "font",
  new CacheFirst({ cacheName: "fonts", plugins: [ok200, expire(30, 365 * DAY)] }),
);

registerRoute(
  ({ request, sameOrigin }) => sameOrigin && request.destination === "image",
  new CacheFirst({ cacheName: "images", plugins: [ok200, expire(150, 30 * DAY)] }),
);

registerRoute(
  ({ request, sameOrigin }) => !sameOrigin && request.destination === "image",
  new StaleWhileRevalidate({
    cacheName: "third-party",
    plugins: [new CacheableResponsePlugin({ statuses: [0, 200] }), expire(40, 7 * DAY)],
  }),
);

registerRoute(
  ({ url, sameOrigin }) => sameOrigin && url.pathname.startsWith("/api/catalog/"),
  new StaleWhileRevalidate({
    cacheName: "api",
    fetchOptions: { cache: "no-cache" },
    plugins: [ok200, expire(80), new BroadcastUpdatePlugin()],
  }),
);

class NetworkAndCache extends Strategy {
  _handle(request, handler) {
    return handler.fetchAndCachePut(request);
  }
}
registerRoute(
  ({ url, sameOrigin }) => sameOrigin && url.pathname === "/api/feed",
  new NetworkAndCache({ cacheName: "api", plugins: [ok200] }),
);

registerRoute(
  ({ url, sameOrigin }) => sameOrigin && url.pathname.startsWith("/api/"),
  new NetworkFirst({ cacheName: "api", networkTimeoutSeconds: 3, plugins: [ok200, expire(80)] }),
);

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

A few design decisions in the vanilla router are worth calling out:

  • Passthrough is the default. Anything not explicitly routed is left to the browser. A catch-all "network first for everything" route looks safe but silently caches responses you never reviewed, including personalized ones.
  • The precache route comes before the navigation route, so /offline.html requested directly is served from the shell cache; but for navigations to any other URL, the navigation route wins before the asset routes can match.
  • Trimming is throttled. cache.keys() on a large cache is not free; running it on every request would put I/O on the hot path. Once per 30 seconds per cache is plenty.
  • route.handle(event) receives the event, not the request. Strategies need the event for waitUntil(), preloadResponse and resultingClientId.
  • Navigation preload is only consumed by network-backed routes. A navigation answered by the precache route (someone opening /offline.html directly) never reads event.preloadResponse, so Chrome cancels the preload and logs a warning. That is harmless; if the noise bothers you, the warning's own advice applies: pass event.preloadResponse to event.waitUntil() in that route, at the cost of letting the unused preload download finish.
  • In the Workbox version, the passthrough listener is registered before Workbox's. The first call that needs the default router (registerRoute(), precacheAndRoute(), setCatchHandler()) makes Workbox add its own fetch listener; listeners run in registration order, so a listener added earlier can call stopImmediatePropagation() to keep a request away from Workbox entirely. Workbox's registerRoute() also warns in development when a match callback returns a promise, because async matching can't work.

Broadcasting updates from stale-while-revalidate

Stale-while-revalidate has a UX gap: the user is looking at stale content, the fresh copy has just landed in the cache, and nobody tells the page. Broadcasting closes the gap. When the background revalidation finds that the response changed, the service worker posts a message to the affected pages, and each page decides what to do: silently re-render data, show a "new content available" toast, or ignore it.

sequenceDiagram
    participant Page
    participant SW as Service worker
    participant Cache as Cache Storage
    participant Net as Network
    Page->>SW: GET /api/catalog/shoes
    SW->>Cache: match(request)
    Cache-->>SW: stale copy, ETag v41
    SW-->>Page: stale copy, rendered immediately
    SW->>Net: fetch with cache no-cache
    Net-->>SW: 200 OK, ETag v42
    SW->>Cache: put(request, response)
    SW->>SW: compare ETag v41 and v42, changed
    SW->>Page: postMessage CACHE_UPDATED
    Page->>Cache: caches.open then match the updated URL
    Cache-->>Page: fresh JSON
    Page->>Page: re-render, no second network request

Detecting that a revalidated response changed

Comparing bodies byte for byte is expensive, so the usual approach compares validators. Workbox's BroadcastCacheUpdate checks headersToCheck, which defaults to ['content-length', 'etag', 'last-modified']. If none of those headers is present on both responses, it can't decide and assumes they are the same, so no message is sent. Otherwise the responses are considered identical only if every listed header has the same presence and the same value on both. Opaque responses have no readable headers, so they never trigger an update message.

That fails for APIs that don't send validators, which is common for JSON. The vanilla version below falls back to hashing both bodies with SHA-256 (crypto.subtle.digest(), available in workers), capped at a size where hashing is cheap. Don't hash HTML that embeds per-request values such as CSRF tokens, nonces or timestamps: every revalidation would look like a change. Send an ETag from the server instead.

Delivering the message to the right pages

There are three delivery mechanisms, and they differ in who receives the message and whether it survives a page that isn't listening yet:

Mechanism Reaches Buffered until the page listens? Notes
client.postMessage() for each of clients.matchAll({ type: "window" }) Every controlled window of the origin Yes, per client: queued until DOMContentLoaded, or until startMessages() is called or navigator.serviceWorker.onmessage is set Workbox's default (notifyAllClients: true)
(await clients.get(event.clientId)).postMessage() Only the page that made the request Yes, same queue Workbox with notifyAllClients: false
new BroadcastChannel(name).postMessage() Every same-origin context with that channel open, controlled or not No: a page that subscribes later misses it Chrome 54, Firefox 38, Safari 15.4

Navigations add a timing problem. When a navigation is answered from cache, the page that will display it doesn't exist yet as a client when revalidation starts. FetchEvent.resultingClientId (Chrome 72, Firefox 65, Safari 16) names the client the navigation will create. Workbox handles this carefully: for navigation requests it polls clients.matchAll() every 100 ms, for up to 2 seconds, until a window with that ID appears. If it doesn't find it, or if the browser is Safari (detected by user agent because of WebKit bug 201169 about postMessage buffering), it waits another 3.5 seconds before sending, a number chosen because, according to the source comment, CrUX data showed 80% of mobile sites reach DOMContentLoaded within 3.5 seconds.

Implementing update broadcasts

sw/lib/broadcast.js
const HEADERS_TO_CHECK = ["etag", "last-modified", "content-length"];
const MAX_HASH_BYTES = 512 * 1024; // hashing beyond this costs more than it saves

/** True when the new response differs from the old one. */
export async function hasChanged(oldResponse, newResponse) {
  if (oldResponse.type === "opaque" || newResponse.type === "opaque") return false; // unknowable

  // 1. Validators, like Workbox: usable if at least one is on both responses.
  const comparable = HEADERS_TO_CHECK.some(
    (h) => oldResponse.headers.has(h) && newResponse.headers.has(h),
  );
  if (comparable) {
    return HEADERS_TO_CHECK.some((h) => oldResponse.headers.get(h) !== newResponse.headers.get(h));
  }

  // 2. No validators: hash bounded bodies (JSON-sized responses).
  const [a, b] = await Promise.all([digest(oldResponse), digest(newResponse)]);
  return a !== null && b !== null && a !== b;
}

async function digest(response) {
  if (Number(response.headers.get("content-length")) > MAX_HASH_BYTES) return null;
  const buffer = await response.arrayBuffer();
  if (buffer.byteLength > MAX_HASH_BYTES) return null;
  const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", buffer));
  return Array.from(hash, (byte) => byte.toString(16).padStart(2, "0")).join("");
}

/** onUpdate hook for staleWhileRevalidate(). */
export async function notifyIfUpdated({ event, request, cacheName, oldResponse, newResponse }) {
  if (!(await hasChanged(oldResponse, newResponse))) return;

  const message = {
    type: "CACHE_UPDATED",
    meta: "sw-broadcast-update",
    payload: { cacheName, updatedURL: request.url, destination: request.destination },
  };

  const targets =
    request.mode === "navigate"
      ? [await waitForWindow(event.resultingClientId)].filter(Boolean)
      : await self.clients.matchAll({ type: "window" });

  for (const client of targets) client.postMessage(message);
}

/** Poll for the window a navigation creates (it may not exist yet). */
async function waitForWindow(id, timeoutMs = 5000) {
  if (!id) return undefined;
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const windows = await self.clients.matchAll({ type: "window" });
    const match = windows.find((client) => client.id === id);
    if (match) return match;
    await new Promise((resolve) => setTimeout(resolve, 100));
  }
  return undefined;
}
src/cache-updates.js
// Page side: react to CACHE_UPDATED from our worker or from Workbox's
// BroadcastUpdatePlugin (same type, meta "workbox-broadcast-update").
const here = () => location.origin + location.pathname + location.search;

if ("serviceWorker" in navigator) {
  navigator.serviceWorker.addEventListener("message", async (event) => {
    const { type, payload } = event.data ?? {};
    if (type !== "CACHE_UPDATED" || !payload) return;
    const { cacheName, updatedURL } = payload;

    if (updatedURL === here()) {
      // The document itself changed. Never reload on the user's behalf:
      // they may be reading, typing, or halfway through a form.
      showUpdateToast("This page has been updated.", () => location.reload());
      return;
    }

    // Data changed: re-render from the fresh cache entry. No second request.
    const response = await (await caches.open(cacheName)).match(updatedURL);
    if (!response?.headers.get("Content-Type")?.includes("json")) return;
    const data = await response.json();
    document.dispatchEvent(new CustomEvent("cache-updated", { detail: { url: updatedURL, data } }));
  });

  // addEventListener() doesn't start the client message queue. Without
  // this call, messages stay queued until DOMContentLoaded.
  navigator.serviceWorker.startMessages();
}

function showUpdateToast(text, onReload) {
  const toast = document.querySelector("#update-toast");
  if (!toast) return;
  toast.querySelector("[data-text]").textContent = text;
  toast.querySelector("button").onclick = onReload;
  toast.hidden = false; // the container has role="status" for screen readers
}
sw.js
import { registerRoute } from "workbox-routing";
import { StaleWhileRevalidate } from "workbox-strategies";
import { BroadcastUpdatePlugin } from "workbox-broadcast-update";

registerRoute(
  ({ url }) => url.pathname.startsWith("/api/catalog/"),
  new StaleWhileRevalidate({
    cacheName: "api",
    plugins: [
      new BroadcastUpdatePlugin({
        // Defaults: ["content-length", "etag", "last-modified"].
        headersToCheck: ["etag", "x-content-version"],
        // Default payload is { cacheName, updatedURL }.
        generatePayload: ({ cacheName, request }) => ({
          cacheName,
          updatedURL: request.url,
          destination: request.destination,
        }),
        // Default true: every window client. false: only the requester.
        notifyAllClients: true,
      }),
    ],
  }),
);

// Message shape delivered to pages:
// { type: "CACHE_UPDATED", meta: "workbox-broadcast-update",
//   payload: { cacheName, updatedURL, destination } }

Broadcast pitfalls

  • Don't broadcast for every asset. Updated images and scripts rarely need the page's attention. Broadcast for data the page renders and for the document itself.
  • Coordinate with service worker updates. If a new deploy changes both the HTML and the service worker, the page may receive a CACHE_UPDATED for the document and a waiting worker. Show one prompt; the update-prompt patterns are covered in Updating Service Workers.
  • Use the message to invalidate, not to transport data. Send the URL and let the page read the cache. Messages can be dropped, and the cache is the source of truth.
  • Handle both message shapes if you migrate between vanilla and Workbox: the type is the same, the meta differs.

Strategy decision flowchart

flowchart TD
    A["Request reaches the fetch handler"] --> B{"GET request?"}
    B -->|No| NO["Network only, queue writes with Background Sync"]
    B -->|Yes| C{"Auth, payment, live or analytics?"}
    C -->|Yes| NO2["Network only, or no respondWith at all"]
    C -->|No| D{"Precached at install?"}
    D -->|Yes| CO["Cache only"]
    D -->|No| M{"Range request for media?"}
    M -->|Yes| RG["Cache only with Range slicing if downloaded, else network"]
    M -->|No| E{"URL changes when content changes?"}
    E -->|Yes| CF["Cache first plus expiration"]
    E -->|No| F{"Must be fresh when online?"}
    F -->|Yes| G{"Can the UI render twice?"}
    G -->|Yes| CTN["Cache then network in the page"]
    G -->|No| NF["Network first with a timeout"]
    F -->|No| H{"Response readable, not opaque?"}
    H -->|Yes| SWR["Stale-while-revalidate plus expiration and update broadcast"]
    H -->|No| SWRO["Stale-while-revalidate in a small purgeable cache"]
    CO --> FB["Generic fallback when everything fails"]
    CF --> FB
    NF --> FB
    CTN --> FB
    SWR --> FB
    SWRO --> FB

Read it top to bottom and stop at the first match, exactly as the router does. Two branches deserve comment. "URL changes when content changes" is the property that makes cache first safe; if you can add content hashes to a class of URLs, you can move it from the stale-while-revalidate branch to the cache-first branch and eliminate its revalidation traffic. And "Can the UI render twice?" is a product question as much as a technical one: cache then network gives the best perceived performance for data views, but only if the second render is smooth.

Browser support

Every strategy on this page is built from primitives that ship in all current engines. The differences are in the helpers you may reach for and in platform-level alternatives.

Feature Chrome / Edge Firefox Safari (macOS / iOS) Used for
Cache Storage API in workers ✅ 40 ✅ 41 ✅ 11.1 / 11.3 Every strategy
FetchEvent.respondWith() ✅ 42 ✅ 44 ✅ 11.1 / 11.3 Every strategy
Navigation preload (preloadResponse) ✅ 59 ✅ 99 ✅ 15.4 Network first for navigations
FetchEvent.resultingClientId ✅ 72 ✅ 65 ✅ 16 Broadcasting navigation updates
Promise.any() ✅ 85 ✅ 79 ✅ 14 Race strategy
AbortSignal.timeout() ✅ 124 ⚠️ ✅ 100 ✅ 16 Network timeouts
AbortSignal.any() ✅ 116 ✅ 124 ✅ 17.4 Combining timeout and caller abort
BroadcastChannel ✅ 54 ✅ 38 ✅ 15.4 Broadcasting to all contexts
Module service workers (type: "module") ✅ 91 ✅ 147 ✅ 15 Code layout on this page
Static routing (InstallEvent.addRoutes()) ✅ 123 ❌ ✅ 27 Bypassing the worker for network-only and cache routes
Network Information (effectiveType, saveData) ✅ 61 / 65 ❌ ❌ Adaptive timeouts, skipping revalidation
HTTP Cache-Control: stale-while-revalidate ✅ 75 ✅ 68 ✅ 14 HTTP-layer SWR under the worker

⚠️ Chrome 103 to 123 shipped AbortSignal.timeout() but aborted with an AbortError instead of a TimeoutError; code that checks error.name === "TimeoutError" should also accept "AbortError" if it must support those versions. Versions in the Chrome / Edge column are Chrome versions; Chromium-based Edge (79 and later) matches them.

Support data as of September 2026. For live data see MDN's Cache compatibility table and caniuse.com: Service Workers.

Beyond API availability, engines differ in storage policy, which affects how long any cache-based strategy's entries survive. Safari can evict script-writable storage (including Cache Storage) for sites the user hasn't interacted with recently, with Home Screen web apps treated differently, and all engines evict whole origins under storage pressure unless storage is persisted. Details, numbers and navigator.storage.persist() are covered in Storage Quotas & Persistence.

Common pitfalls

These are the failures that show up in production across strategies, roughly in order of how often they bite.

Serving a redirected response to a navigation. Navigation requests use redirect mode "manual". If the response you return has redirected === true (because it was cached from a fetch that followed a redirect, for example precaching / when the server redirects it to /home), the browser rejects it and the navigation fails with a network error; Chrome logs that a redirected response was used for a request whose redirect mode is not "follow". Workbox's precaching cleans such responses with copyResponse() from workbox-core; the vanilla equivalent is below.

sw/lib/clean-redirect.js
/**
 * Return a copy of `response` whose `redirected` flag is false, so it can be
 * stored and later served for navigations.
 */
export async function cleanRedirect(response) {
  if (!response.redirected) return response;
  // Reading into a Blob is the most compatible way to detach the body.
  const body = await response.blob();
  return new Response(body, {
    status: response.status,
    statusText: response.statusText,
    headers: response.headers,
  });
}

Consuming a body twice. cache.put(request, response) followed by return response fails with a TypeError because the body has already been used. Clone before either consumer starts reading: cache.put(request, response.clone()).

Calling respondWith() after an await. event.respondWith() after any await in the listener throws InvalidStateError, because dispatch has finished, and the browser has already fallen back to the network. Call it synchronously with a promise.

Caching personalized responses across sessions. Cache Storage is per origin, not per user. After logout, the next user on a shared device can be served the previous user's cached /api/me or account pages. Purge personalized caches on logout (from the page, await caches.delete("api") works too, since windows have caches), keep personalized and public data in separate caches, and consider the Clear-Site-Data response header on your logout endpoint (see MDN for what each directive clears). More in Privacy & Storage Partitioning.

Caching errors. A cacheFirst without a status check stores 404 pages and 500 errors and serves them forever. Opaque responses make the error invisible. Only store status 200 (or deliberate exceptions such as a 404 you want cached briefly).

Overusing ignoreSearch. It makes /api/products?page=2 match the cached /api/products?page=1. Use it only for routes where the query string is known to be irrelevant (tracking parameters on navigations), or strip known parameters from the URL before matching.

Cache first for unversioned URLs. The single most common "my PWA never updates" bug. Caches survive service worker updates by design. Version the URLs, or pick stale-while-revalidate or network first.

Unbounded runtime caches. Every cache-writing route without limits grows until the browser evicts your origin's storage under pressure, which takes the precached shell with it. Every runtime cache needs a maxEntries.

Stale service worker, stale strategy. Strategies live in sw.js, so a strategy fix only takes effect when the new worker activates. If the worker script itself is HTTP-cached, even that is delayed. Serve sw.js with Cache-Control: no-cache and see Updating Service Workers.

Doing expensive work before choosing a route. Match functions run for every request the page makes. Keep them synchronous and cheap (string prefix checks, a precompiled RegExp, a Set lookup); never open caches or IndexedDB just to decide whether to handle a request.

Assuming navigator.onLine tells you anything. navigator.onLine === true only means there is a network interface. Strategies should react to actual fetch() outcomes and timeouts, never to onLine.

Debugging caching strategies

Strategy bugs are usually "the response came from the wrong place", so the first job is making the source visible.

  • Chrome and Edge DevTools: Application > Cache storage lists every cache and entry, with response headers and a filter box; you can delete individual entries or whole caches. In the Network panel, responses supplied by a service worker show (ServiceWorker) in the Size column, and requests the worker itself makes are listed separately with a gear icon. Application > Service workers has Offline, Update on reload and Bypass for network; use Network throttling presets or a custom profile with high latency to exercise network-first timeouts. chrome://serviceworker-internals shows every registration and its logs.
  • Firefox: about:debugging#/runtime/this-firefox lists registered workers with an Inspect button for the worker's console and debugger; the Storage panel shows Cache Storage.
  • Safari: Develop > Service Workers opens an inspector per registration, including for Home Screen web apps on a tethered iOS device.
  • Tag responses in development builds. Wrapping a response to add headers costs a new Response() around the same body stream. Only do it in development, and never for opaque responses (they can't be reconstructed).
sw/lib/debug.js
const DEV = self.location.hostname === "localhost";

/** Add X-SW-Strategy / X-SW-Source headers in development builds. */
export function tag(response, strategy, source) {
  if (!DEV || response.type === "opaque" || response.type === "opaqueredirect") return response;
  const headers = new Headers(response.headers);
  headers.set("X-SW-Strategy", strategy);
  headers.set("X-SW-Source", source); // "cache" | "network" | "fallback"
  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers,
  });
}

For automated coverage, drive the page with Playwright or Puppeteer, toggle offline mode per test, and assert on response.fromServiceWorker() and on cache contents read with page.evaluate(() => caches.keys()); see Automated Testing and Browser DevTools.

Further reading

On this site

External references