Skip to content

Handling Fetch Events in Service Workers

The fetch event is how a service worker sits between your pages and the network: every request a controlled page makes, including the navigation itself, scripts, images and fetch() calls, can be dispatched to your worker as a FetchEvent, and whatever Response you pass to event.respondWith() is what the page receives. Getting it right means knowing exactly which requests arrive, what the Request object tells you, the strict rules respondWith() enforces, and which responses the browser will turn into network errors. This page covers all of it at the level of the specifications and browser source code, and ends with a complete, dependency-free router you can adapt.

Key takeaways

  • Call respondWith() synchronously inside the listener, at most once, with a Response or a promise for one. A rejected promise, a non-Response value or a disallowed response type becomes a network error for the page.
  • If you do not call respondWith(), the request goes to the network as if no service worker existed. The worker still had to start, so a fetch handler that mostly falls through is pure overhead.
  • event.request carries mode, destination, credentials, cache, redirect, integrity and more. fetch(event.request) preserves them; fetch(event.request.url) throws them away.
  • Opaque responses (cross-origin no-cors) are unreadable, can only answer no-cors requests, and Chromium adds a pseudo-random quota padding of 0 to about 14 MiB to each one (about 7 MiB on average).
  • Navigations use redirect mode manual, so a cached response with redirected === true cannot answer them. Copy it into a clean Response first.
  • The Cache API ignores Range headers and refuses to store 206 responses, so serving cached audio and video means building the 206 yourself.
  • Match routes synchronously on mode, destination, method and URLPattern, then do all asynchronous work inside the promise you pass to respondWith().

How a request reaches your fetch handler

The Fetch standard hands every request whose service-workers mode is "all" to the Service Workers specification's Handle Fetch algorithm before touching the network. Handle Fetch decides which registration (if any) gets the request, starts the worker if needed and dispatches the FetchEvent. What happens next depends on whether the request is a navigation or a subresource.

The spec splits requests into two groups:

Non-subresource requests
Requests that create a new client: document navigations (destination document, iframe, frame) and the script requests for dedicated and shared workers. For these, the browser matches the request URL against registration scopes at that moment. If a registration with an active worker matches, that worker becomes the new client's controller, receives the fetch event, and a soft update check runs in the background (see Updating Service Workers).
Subresource requests
Everything a client requests after it exists: scripts, stylesheets, images, fonts, fetch(), XMLHttpRequest, EventSource and so on. These go to the client's active service worker, the one that was assigned when the client was created (or later through clients.claim()). Scope is never re-evaluated, which is why a page that loaded before your worker activated stays uncontrolled until it reloads or you claim it (see Lifecycle).

A direct consequence: cross-origin subresources go through your worker. When a controlled page loads an image from a CDN or a script from an analytics domain, your fetch handler sees that request. A cross-origin iframe is different: its navigation is matched against the scopes of the iframe's own origin (in a partitioned storage key when third-party), so your worker never sees it or anything inside it.

sequenceDiagram
    participant Page as Controlled page
    participant UA as Browser fetch
    participant SW as Service worker
    participant Net as Network or HTTP cache
    Page->>UA: GET /api/items
    UA->>UA: "service-workers mode is all?"
    UA->>SW: start worker if not running
    UA->>SW: dispatch FetchEvent
    alt respondWith called
        SW-->>UA: Response or promise
        UA->>UA: validate type, redirect, body
        UA-->>Page: response or network error
    else no respondWith
        UA->>Net: perform the request normally
        Net-->>Page: response
    end

Requests that never reach the fetch event

Knowing what bypasses your handler is as important as knowing what reaches it:

Request Why it skips the fetch event
<embed> and <object> loads Handle Fetch returns early for the embed and object destinations.
Navigations started with shift+reload (hard reload) The spec returns early; the resulting page is uncontrolled, so its subresources skip the worker too.
Requests made by the service worker itself fetch() called from a ServiceWorkerGlobalScope sets service-workers mode to "none", so your handler never recurses into itself.
The worker script, importScripts() and update checks Service worker script fetches are never intercepted.
The navigation preload request Its service-workers mode is set to "none" (see Navigation Preload).
Individual redirect hops of a subresource With redirect mode follow, the Fetch standard sets service-workers mode to "none" for network redirects: "Redirects coming from the network (as opposed to from a service worker) are not to be exposed to a service worker." You only see the original request.
Requests matched by a static route with source network or cache The browser answers them without starting your worker (see Static Routing API).
Workers without a fetch listener, and Chromium's no-op listeners See The cost of a fetch handler.
fetchLater() deferred requests (Chromium 135+) The Fetch standard's queue a deferred fetch sets service-workers mode to "none", so a beacon sent with fetchLater() goes straight to the network.
FedCM (webidentity) and service worker script requests The Fetch standard notes that the webidentity and serviceworker destinations are not reflected in RequestDestination because fetches with them "skip service workers".
WebSocket and WebTransport connections Their request modes are internal and never exposed to service workers.
Pages outside every scope, and non-secure contexts No registration matches, so there is no worker to dispatch to.

What happens between the request and your listener

The Handle Fetch algorithm and its helper, Create Fetch Event and Dispatch, run these steps in order. Knowing the order explains several behaviors that otherwise look random:

  1. Pick the registration. For a non-subresource request, match the URL against scopes (and bail out for embed/object, non-secure contexts and shift+reload). For a subresource, use the client's active worker, or bail out if it has none.
  2. Compute shouldSoftUpdate. True for every non-subresource request, and for a subresource request when the registration is stale: its last update check was more than 86,400 seconds (24 hours) ago.
  3. Evaluate static routes, if the active worker registered any. A network or cache match returns before your code is involved; race-network-and-fetch-handler starts its own network request.
  4. Start the navigation preload request, if the request is a GET navigation, the worker handles fetch, its listeners are not all empty, and preload is enabled (see Navigation Preload). Note that this check happens before step 6, so a navigation that arrives while the worker is still activating is judged by whatever the preload flag was at that moment.
  5. Skip the event entirely if the worker has no fetch listener (the Should Skip Event algorithm), or if all its fetch listeners are empty. In the second case the worker is still started in the background so the soft update can run.
  6. Wait for activation. If the active worker's state is activating, wait until it is activated. A slow activate handler therefore delays every request the new worker controls.
  7. Run the worker if it is not running (the expensive part; see the cost section).
  8. Queue a task on the handle fetch task source that creates the FetchEvent, including a fresh AbortController whose signal becomes event.request.signal, and dispatches it to every fetch listener in registration order until one calls respondWith().
  9. Wait for the response, then run the soft update in parallel if shouldSoftUpdate is true.

Anatomy of a FetchEvent

The current Service Workers specification defines the interface like this:

FetchEvent IDL (Service Workers spec)
[Exposed=ServiceWorker]
interface FetchEvent : ExtendableEvent {
  constructor(DOMString type, FetchEventInit eventInitDict);
  [SameObject] readonly attribute Request request;
  readonly attribute Promise<any> preloadResponse;
  readonly attribute DOMString clientId;
  readonly attribute DOMString resultingClientId;
  readonly attribute DOMString replacesClientId;
  readonly attribute Promise<undefined> handled;

  undefined respondWith(Promise<Response> r);
};
Member What it gives you Notes
request The Request the browser wants to make Headers are immutable; clone before reading a body.
respondWith(r) Takes over the response Synchronous, once, Response or promise.
waitUntil(p) Inherited from ExtendableEvent; keeps the worker alive for p Use for cache writes and logging after responding.
preloadResponse Promise for the navigation preload response, or undefined Only for GET navigations with preload enabled.
clientId ID of the client that made the request Empty string when there is no initiating client.
resultingClientId ID of the client this request will create Only for navigations and worker scripts.
replacesClientId ID of the client being replaced by a navigation In the spec's IDL, but no current browser exposes it.
handled Promise that settles when the browser has the outcome Rejects with NetworkError if handling failed.

The legacy event.isReload attribute was never standardized: Firefox removed it in version 74, Safari never had it, and Chromium still exposes it only for compatibility. Use request.cache or the Chromium-only request.isHistoryNavigation if you need navigation hints.

clientId, resultingClientId and replacesClientId

The three ID attributes tell you who is asking and what the request will create:

Request clientId resultingClientId
Subresource from a page (<img>, fetch()) That page's client ID ""
Subresource from a dedicated worker The worker's client ID ""
Top-level navigation from the address bar "" (no initiating client) ID of the new document's environment
Navigation triggered by a link in another tab Depends on the initiator; treat as unreliable ID of the new document's environment
new Worker("/w.js") script request The creating page's ID ID of the new worker's environment
Requests with destination report Varies Always ""

resultingClientId is the reliable handle for "the page this navigation is about to create". Pass it to clients.get() to message the new page, but never wait for it inside the promise you pass to respondWith(). The spec's clients.get() waits for the target client to become execution ready, and a document cannot become execution ready until it has received the response you are still computing. Awaiting it there hangs the navigation until the browser gives up on the event.

sw.js - message the page a navigation creates
self.addEventListener("fetch", (event) => {
  if (event.request.mode !== "navigate") return;

  event.respondWith(handleNavigation(event)); // never awaits the client

  // Separate lifetime promise: runs after the document exists.
  event.waitUntil(
    (async () => {
      if (!event.resultingClientId) return;
      const client = await self.clients.get(event.resultingClientId);
      // Messages are queued until the page's client message queue is enabled
      // (onmessage set, startMessages() called, or DOMContentLoaded).
      client?.postMessage({ type: "SW_VERSION", version: "2026-09-25" });
    })(),
  );
});

replacesClientId still appears in the IDL, and the spec initializes it from the navigation request's replaces client id, but no engine ships it. Safari exposed an earlier, non-standard spelling, targetClientId, from Safari 11.1 until Safari 16; MDN's browser-compat-data removed the entry in October 2024 after its collector confirmed that no browser supports it, and MDN removed the reference page in September 2026. Treat it as absent: to learn which page a navigation replaced, have that page tell you with postMessage() before it unloads, or track clients yourself (see Messaging & the Clients API).

event.handled: knowing when the page has its response

event.handled starts pending and settles when fetch handling finishes. It resolves when the browser received a response from respondWith(), or when you did not call respondWith() and the request fell through to the network. It rejects with a NetworkError DOMException when handling produced a network error. In the spec that means the promise passed to respondWith() rejected or fulfilled with something that is not a Response, you called event.preventDefault() without calling respondWith(), or the worker could not run. Chromium goes further: its FetchRespondWithObserver also rejects handled for every response its type checks refuse (an opaque response for a cors request, a redirected response for a navigation, a used or locked body, and the rest of the table in Everything that becomes a network error). It is useful for deferring non-critical work until the page is no longer waiting:

sw.js - defer work until the response has been handed over
self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);
  if (url.pathname !== "/article") return;

  event.respondWith(caches.match(event.request).then((r) => r ?? fetch(event.request)));

  event.waitUntil(
    event.handled
      .then(() => recordView(url)) // runs only after the browser has the response
      .catch(() => {
        /* NetworkError: nothing was served, nothing to record */
      }),
  );
});

async function recordView(url) {
  // Low-priority bookkeeping that should never compete with the response.
  const cache = await caches.open("stats");
  await cache.put(`/__stats${url.pathname}`, new Response(String(Date.now())));
}

handled shipped in Chrome 86, Firefox 84 and Safari 16. In older engines event.handled is undefined, so guard with event.handled?.then(...) if you still support them.

waitUntil() in fetch events

respondWith(r) adds r to the event's lifetime promises exactly as if you had called waitUntil(r). Everything else you want to finish after the response goes into event.waitUntil(): writing a clone to the cache, updating a stale entry, sending a log. Three rules matter:

  • Asynchronous calls are allowed only while the event is still active. You may call waitUntil() from inside a .then() as long as at least one earlier lifetime promise is still pending. Once all of them have settled, a late call throws InvalidStateError; Chrome's message is "The event handler is already finished and no extend lifetime promises are outstanding." Register a long-lived promise early (for example the network request itself) when later callbacks will add more work.
  • The browser still enforces time limits. Chromium gives each event 5 minutes (kRequestTimeout in its source) before it may terminate the worker, and it stops idle workers after about 30 seconds without events. A waitUntil() that hangs forever gets the worker killed, along with any other work in flight. Details are on the Lifecycle page.
  • Rejections do not affect the response. A rejected waitUntil() promise in a fetch event does not turn the response into an error, but it does hide failures. Always .catch() and log cache writes, because QuotaExceededError is common in production (see Storage Quotas & Persistence).

The Request object inside a fetch event

event.request is a normal Request object created by the browser, with two differences from the ones you construct: its headers guard is "immutable", so event.request.headers.set() throws a TypeError, and its signal is wired to the page's fetch controller. Every property tells you something useful for routing.

url, method and headers

request.url is the full, serialized URL. Parse it once with new URL(request.url) and route on origin, pathname and searchParams; string checks such as url.includes("/api/") match query strings and other origins by accident. request.method is normalized to upper case only for DELETE, GET, HEAD, OPTIONS, POST and PUT; any other method keeps the case the page used, so fetch(url, { method: "patch" }) arrives as patch, not PATCH. Compare methods case-insensitively if your pages are inconsistent. Header names in request.headers are case-insensitive; the ones worth reading are Accept, Range, Content-Type, Authorization and any custom headers your app sets, such as an API version.

Browsers do not expose everything they will eventually send. Cookies, the Origin header and other headers the browser adds late are not in event.request.headers; they are added again when you call fetch(event.request).

mode: navigate, same-origin, no-cors, cors

request.mode decides which response types you may return and how the browser treats cross-origin data:

Mode Typical initiators You may respond with
navigate Top-level documents and iframes basic or synthesized (default) responses, and opaqueredirect (navigations use redirect mode manual). Never opaque.
same-origin fetch(url, { mode: "same-origin" }), some worker scripts Same-origin responses only; a cors response is a network error.
no-cors <img>, <script> and <link rel=stylesheet> without crossorigin, <video>, <audio>, CSS background-image Anything, including opaque.
cors fetch() (the default), XMLHttpRequest, @font-face, module scripts, elements with crossorigin basic, cors or synthesized. An opaque response is a network error.

Two details trip people up. First, navigate cannot be created by script: new Request(url, { mode: "navigate" }) throws a TypeError, and new Request(event.request, init) with a non-empty init silently converts navigate to same-origin. Passing the original event.request object to fetch() is the only way to preserve navigation semantics. Second, the Fetch standard strongly discourages no-cors for new features: it exists for legacy HTML elements, and it is the source of every opaque response you will ever handle.

destination: what the response will be used for

request.destination is the most useful routing signal after the URL, because it tells you how the response will be consumed regardless of the file extension. The Fetch standard's RequestDestination values and their initiators:

destination Initiated by CSP directive
"" (empty string) fetch(), XMLHttpRequest, navigator.sendBeacon(), EventSource, <a ping>, <link rel=prefetch>, downloads connect-src (varies)
document Top-level navigations -
iframe, frame <iframe>, <frame> child-src / frame-src
image <img>, srcset, <picture>, SVG <image>, CSS images, /favicon.ico img-src
style <link rel=stylesheet>, CSS @import, import ... with { type: "css" } style-src
script <script>, importScripts() from workers script-src
font CSS @font-face font-src
audio, video, track <audio>, <video>, <track> media-src
manifest <link rel=manifest> manifest-src
worker, sharedworker new Worker(), new SharedWorker() worker-src
audioworklet, paintworklet audioWorklet.addModule(), CSS.paintWorklet.addModule() script-src
json import ... with { type: "json" } connect-src
text import ... with { type: "text" } (new: Firefox 153; Chrome 155 is in beta as of September 2026) connect-src
report CSP and Network Error Logging reports -
xslt <?xml-stylesheet?> script-src
embed, object <embed>, <object> (never dispatched to a worker) object-src

The empty string is the important trap: every fetch() call from your application code has destination === "", so "API request" and "fetch() of a JSON file" look identical here. Route those by URL. Treat destinations you do not recognize as "fall through", because the list grows (Chromium also reports speculationrules, which is not in the standard enum yet).

credentials

request.credentials is omit, same-origin or include. Navigations and no-cors element loads use include, fetch() defaults to same-origin, and crossorigin="anonymous" on an element produces a cors request with same-origin credentials. When you re-issue a request with fetch(event.request) the browser keeps this setting, which is what you want. When you build a new request, you choose: a common mistake is converting a no-cors image request into new Request(url, { mode: "cors" }) and forgetting that the default credentials mode is now same-origin, so cookies that the image server relied on are no longer sent.

cache: the HTTP cache mode

request.cache reflects the page's instruction for the browser's HTTP cache, and fetch(event.request) honors it:

Value Meaning when you call fetch(event.request)
default Normal HTTP caching: fresh entries are used, stale ones revalidated.
no-store Bypass the HTTP cache completely and do not store the response.
reload Go to the network, then update the HTTP cache.
no-cache Use a cached entry only after revalidating with the server.
force-cache Use any cached entry, even stale; otherwise go to the network.
only-if-cached Only the HTTP cache; a miss is a network error. Allowed only with mode same-origin.

The only-if-cached restriction is enforced by the Request constructor: if the browser ever hands you a request with cache: "only-if-cached" and a mode other than same-origin, fetch(event.request) throws "'only-if-cached' can be set only with 'same-origin' mode" (Chrome's wording). Defensive handlers skip such requests entirely. Remember too that the Cache API ignores request.cache: a page that fetched with cache: "no-store" will still get a Cache Storage hit if your strategy is cache-first. Respect it explicitly if it matters to your app:

sw.js - honor the page's cache opt-outs
function wantsFreshNetwork(request) {
  return request.cache === "no-store" || request.cache === "reload";
}

The interaction between Cache Storage and the HTTP cache is covered in HTTP Caching & Service Workers.

redirect

request.redirect is follow (the default for fetch() and element loads), error or manual. Navigation requests always use manual, because the HTML navigation algorithm handles redirects itself. This has two consequences you must design for, both covered in Redirects:

  1. fetch(event.request) for a navigation does not follow redirects. You get back a response of type opaqueredirect (status 0, no readable headers) that you can pass straight to respondWith(); the browser then performs the redirect and the next URL produces a new fetch event.
  2. You cannot answer a navigation with a response whose redirected flag is true.

integrity, keepalive, signal and the rest

integrity
The Subresource Integrity metadata from the element (for example sha384-...). The integrity check runs in the Fetch standard's main fetch on whatever response comes back, including one your worker supplied. You cannot bypass SRI from a service worker: if a cached copy does not match the hash, the page gets a network error. An opaque response can never pass an integrity check, which is why SRI-protected cross-origin scripts need crossorigin.
keepalive
true for requests that must outlive the page, such as navigator.sendBeacon() and fetch(url, { keepalive: true }). The Fetch standard caps the total in-flight keepalive body size at 64 KiB per fetch group. Respond quickly or not at all; the page may be gone by the time you finish.
signal
An AbortSignal that the spec says is aborted when the page no longer wants the response (the page aborted its fetch(), or navigated away). Forward it to any sub-request you make with a different Request: fetch(url, { signal: event.request.signal }). Implementations have lagged; Firefox's bug 1394102 remains open, so treat it as a best-effort optimization, not a correctness guarantee.
referrer, referrerPolicy
The referrer the browser will send, as "about:client" or a URL, and the policy that governs it.
body, bodyUsed, duplex
See POST requests and request bodies. request.body is a ReadableStream in Chromium and Safari; Firefox has not shipped the getter in a stable release, so read bodies with arrayBuffer(), text(), json() or formData() for portability. bytes(), which returns a Uint8Array, is available in Chrome 132, Firefox 128 and Safari 18.
isHistoryNavigation, isReloadNavigation
Chromium-only hints (Chrome 69 and, per MDN's data, Chrome 150 respectively) that tell you whether a navigation came from back/forward or from a reload. Useful for choosing a cache-first response on back navigation, but always feature-detect.

fetch(event.request) versus fetch(event.request.url)

These two calls look interchangeable and are not:

Aspect fetch(event.request) fetch(event.request.url)
Mode Preserved (navigate, no-cors, ...) Always cors
Credentials Preserved same-origin
Method and body Preserved (body consumed) Always GET, no body
Headers (Range, custom, Accept) Preserved Lost
Redirect mode Preserved (manual for navigations) follow
Cache mode, integrity, referrer Preserved Defaults
Cross-origin no-cors image Opaque response, works CORS request, fails without Access-Control-Allow-Origin

Use the URL form only when you deliberately want a different request, for example fetching a canonical cache key without query parameters.

Modifying a request: new Request(event.request, init)

Because event.request.headers is immutable, changing anything means constructing a new Request. The constructor applies rules that matter here:

  • If init is non-empty, navigate mode becomes same-origin, the reload and history-navigation flags are cleared, and the referrer and origin are reset to the worker's client. The spec explains why: a request "redirected" by a service worker should no longer appear to come from the original source.
  • In a no-cors request with a non-empty init, privileged no-CORS request-headers are removed. Today that list is exactly one header: Range. Rebuilding a media request to add a header silently turns a range request into a full download.
  • A no-cors request with a method other than GET, HEAD or POST throws a TypeError.
  • Passing a stream body requires duplex: "half" and a mode of same-origin or cors.
  • Reusing event.request as input transfers its body: after new Request(event.request, { headers }), event.request.bodyUsed is true.
sw.js - adding a header to same-origin API calls
function withApiVersion(request) {
  const headers = new Headers(request.headers); // mutable copy
  headers.set("X-API-Version", "2026-09");
  // mode/credentials/cache/redirect/body are copied from the original request.
  return new Request(request, { headers });
}

self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);
  if (url.origin !== self.location.origin || !url.pathname.startsWith("/api/")) return;
  event.respondWith(fetch(withApiVersion(event.request)));
});

The rules of respondWith()

respondWith() is short to call and strict in what it accepts. The method steps in the Service Workers specification, plus the checks the Fetch standard applies to the result, give you the complete list.

Call it synchronously, during dispatch

The first step of respondWith() is: if the event's dispatch flag is unset, throw InvalidStateError. The dispatch flag is only set while listeners are running, so any call after your listener returns (after an await, in a .then(), in a setTimeout()) throws. Chrome's message is "The event handler is already finished."

sw.js - wrong vs right
// WRONG: respondWith() runs after the listener returned.
self.addEventListener("fetch", async (event) => {
  const cached = await caches.match(event.request);
  event.respondWith(cached ?? fetch(event.request)); // InvalidStateError
});

// RIGHT: call respondWith() immediately with a promise and await inside it.
self.addEventListener("fetch", (event) => {
  event.respondWith(
    (async () => {
      const cached = await caches.match(event.request);
      return cached ?? fetch(event.request);
    })(),
  );
});

The corollary is that the decision whether to respond must be synchronous. You can decide based on anything available without awaiting: the URL, method, mode, destination, headers, and state in global variables. You cannot first check whether something is cached and then decide to fall through. If you need that, commit to responding and fall back to fetch(event.request) inside the promise, or use the Static Routing API with a cache source, which the browser evaluates without your code.

Call it once, and know that it stops other listeners

The second step throws InvalidStateError if respondWith() was already called ("respondWith() was already called." in Chrome). The method also sets the event's stop propagation and stop immediate propagation flags, so once one listener responds, later fetch listeners never run. With several listeners, registration order is your routing order:

sw.js - listener order is routing order
importScripts("/sw/analytics-proxy.js"); // registers a fetch listener first
importScripts("/sw/app-router.js");      // only sees requests the first one ignored

Frameworks and libraries that add their own fetch listener (Workbox's registerRoute(), push SDKs, analytics proxies) interact through this rule; list them in the order you want them to get first refusal.

Pass a Response or a promise for one

respondWith() accepts a Response or a promise. Upon fulfillment, if the value is not a Response object, the event's respond-with error flag is set and the page gets a network error ("an object that was not a Response was passed to respondWith()."). Returning undefined from a cache miss is the classic way to hit this:

sw.js - the undefined trap
event.respondWith(caches.match(event.request)); // undefined on a miss -> network error

event.respondWith(
  caches.match(event.request).then((cached) => cached ?? fetch(event.request)),
);

Everything that becomes a network error

After your promise fulfills, the browser validates the response. The table combines the Fetch standard's checks in HTTP fetch with the exact console messages from Chromium's fetch_respond_with_observer.cc; every Chromium message is prefixed with The FetchEvent for "<url>" resulted in a network error response:.

Condition Chromium console message
The promise passed to respondWith() rejected "the promise was rejected."
preventDefault() was called without respondWith() "preventDefault() was called without calling respondWith()."
The value was not a Response "an object that was not a Response was passed to respondWith()."
Response.error() (type error) "the promise was resolved with an error response object."
opaque response for a request whose mode is not no-cors "an "opaque" response was used for a request whose type is not no-cors"
opaque response for a navigation or worker script "an "opaque" response was used for a client request."
opaqueredirect response for a request whose redirect mode is not manual "an "opaqueredirect" type response was used for a request whose redirect mode is not "manual"."
cors response for a same-origin request "a "cors" type response was used for a request whose mode is "same-origin"."
redirected response for a request whose redirect mode is not follow "a redirected response was used for a request whose redirect mode is not "follow"."
Body already read (bodyUsed is true) "a Response whose "bodyUsed" is "true" cannot be used to respond to a request."
Body locked by a reader "a Response whose "body" is locked cannot be used to respond to a request."
COEP require-corp page and a response that fails the CORP check "Cross-Origin-Resource-Policy prevented from serving the response to the client."

Per MDN's compatibility data, the same-origin/cors check is enforced by Chrome (66+) and Firefox (59+) but not by Safari, so a Safari-only test run can hide that bug.

Why these rules exist

Each check blocks a way to launder cross-origin data. Returning an opaque image response to a cors fetch() would let script read another origin's bytes; answering a navigation with an opaque response would render a cross-origin document at your URL. The restrictions are what keep a service worker, which sees every cross-origin request a page makes, from becoming a cross-origin read primitive. See Service Worker Security.

preventDefault() without respondWith()

FetchEvent is cancelable. If a listener calls event.preventDefault() and nobody calls respondWith(), the spec returns a network error to the page instead of falling back to the network. This is a way to block a request deliberately (for example, a tracking pixel on a privacy-sensitive page), and a nasty surprise if a shared helper calls preventDefault() out of habit.

Never let the promise reject

A rejection is a network error, and a network error for a navigation is the browser's offline dinosaur or error page, not yours. Structure every handler so the outermost promise always resolves with some Response: cached content, an offline page, a synthesized JSON error, or at worst an explicit Response.error() for subresources where no fallback makes sense. The router at the end of this page centralizes that in a catch handler.

Falling through to the network

If every listener returns without calling respondWith() (and nobody called preventDefault()), Handle Fetch returns nothing and the Fetch standard carries on with the original request exactly as if the worker did not exist: HTTP cache, network, redirects, CORS, all handled by the browser. This is the cheapest way to say "not mine", and it is almost always better than event.respondWith(fetch(event.request)) for requests you do not intend to change.

Aspect Falling through (no respondWith()) respondWith(fetch(event.request))
Worker start-up and dispatch Paid Paid
Response body path Network to page directly Network to worker to page
Headers such as Range Always preserved Preserved in current Chromium and Safari; web.dev reported in 2020 that Firefox dropped Range
Redirects Handled by the browser Handled, but you must respect the redirect-mode rules
Failure mode Normal network error handling A rejected promise becomes a network error you might have prevented
DevTools A normal request Two entries: the page's request served by the worker, and the worker's own fetch()

Guard clauses at the top of the listener are the idiomatic way to fall through:

sw.js - explicit opt-outs before any routing
self.addEventListener("fetch", (event) => {
  const { request } = event;

  if (request.method !== "GET") return; // writes go straight to the server
  if (request.headers.has("range")) return; // media streaming: let the browser do it
  if (request.cache === "only-if-cached" && request.mode !== "same-origin") return;

  const url = new URL(request.url);
  if (url.origin !== self.location.origin) return; // third-party: not ours to cache
  if (url.pathname.startsWith("/admin/")) return; // never serve admin pages from cache

  event.respondWith(handle(event));
});

One subtle rule applies to request bodies. For bodies backed by bytes (form posts, JSON, FormData) the browser can resend them after you fall through even if your worker read its own copy. For a body created from a ReadableStream, the spec says that if the worker read that body and then did not respond, the request fails with a network error, because the bytes are gone. Only read streamed bodies in requests you will answer.

The cost of a fetch handler

A fetch listener changes the performance profile of every page it controls, even for requests it ignores. When the worker is not running, which in Chromium happens after about 30 seconds without events, the browser must start a thread, evaluate your script and dispatch the event before the request can proceed. Jake Archibald's web.dev article on navigation preload says boot-up is "usually around 50ms", "more like 250ms" on mobile, and can exceed 500 ms in extreme cases (slow devices, CPU under pressure). That delay sits in front of the navigation request, the most latency-sensitive request your site has.

The no-op fetch handler anti-pattern

For years, Chrome's installability check required a service worker with a fetch handler, so many sites shipped this:

sw.js - do not do this
self.addEventListener("fetch", () => {}); // "makes the site installable"

It adds cost and does nothing. Chromium measured the damage: its March 2023 intent to ship said that 3-5% of the popular sites it investigated were affected. Two changes followed:

  • Chromium skips no-op handlers. Chrome 112 started logging a console warning for them, and Chrome Platform Status records the skip itself as enabled by default from Chrome 115 on desktop and Android. When every fetch listener is a function with an empty body (Blink asks V8 whether each listener is a "nop function"), the browser does not put worker start-up and dispatch on the navigation's critical path, ignores navigation preload, and logs: "Fetch event handler is recognized as no-op. No-op fetch handler may bring overhead during navigation. Consider removing the handler if possible." The spec models this as the all fetch listeners are empty flag; the worker is still started in the background so the soft update check runs.
  • The fetch-handler requirement for installability is gone. Chrome dropped it for installation from the browser menu in version 108 on Android and 112 on desktop (the automatic prompt still required a fetch handler at that time), and current Chromium's install promotion pipeline has no service worker check at all (see Installability Criteria).

A handler with any real statement in it is not skippable, so "almost empty" handlers ((e) => { if (DEBUG) log(e); }) pay the full price. If your worker does not need to intercept requests (for example it only handles push), do not add a fetch listener at all.

Register listeners during the initial evaluation

The browser records which functional events a worker handles after the script's first evaluation. Chromium warns "Event handler of 'fetch' event must be added on the initial evaluation of worker script." if you add one later, for example inside a promise or after importScripts() inside a function, and a worker that had no fetch listener at evaluation time may never receive fetch events at all. Add every listener at the top level, synchronously.

Reducing the cost when you do need a handler

  • Narrow the scope. A worker registered for /app/ never starts for /blog/ navigations (see Registration & Scope).
  • Enable navigation preload so the navigation request runs in parallel with worker start-up (Navigation Preload).
  • Declare bypass routes with the Static Routing API, supported in Chrome 123+ and Safari 27, so requests you would fall through on never wake the worker (Static Routing API).
  • Keep activate fast, because the first requests of a new worker wait for it.
  • Keep the top-level script small. Everything outside listeners runs on every cold start.

Chromium has also been rolling out an optional browser optimization called ServiceWorkerAutoPreload, which issues the navigation request in parallel with worker start-up automatically and hands the result to your fetch(event.request) or to the network fallback. According to its Chrome Platform Status entry and explainer:

  • It applies only to main-resource GET requests, is applied at the browser's discretion (the explainer's eligibility criteria favor workers that often fall back to the network and workers that are not already running), and is never used when you have enabled navigation preload yourself.
  • The auto-preloaded response is consumed only by a fetch() call for a request equal to event.request. If you build a new Request or clone and modify it, the browser discards the auto-preload and makes a fresh request; if you answer from the cache, it is simply discarded (and may be cancelled).
  • Redirects are still handed to your handler, so it sees every hop it would see without the optimization.
  • Chrome Platform Status gives Chrome 140 as its shipping milestone, when the ServiceWorkerAutoPreloadEnabled enterprise policy became available to administrators, and lists Chrome 154 (stable since September 22, 2026) as the milestone in which that policy is removed; its summary describes the optimization as part of Chrome 154. Because the browser decides when to apply it, you cannot assume it is active for any given user.
  • The explainer's documented opt-out is a static route that sends every URL to the fetch handler ({ condition: { urlPattern: new URLPattern({}) }, source: "fetch-event" }); the spec only allows auto-preload when the matched router source is not fetch-event.

Do not design around it: enabling navigation preload explicitly gives you the same benefit in every engine that supports it, plus control over the server response.

Handling navigations and subresources differently

Navigations and subresource requests have different stakes and different rules, so most production workers route them separately.

Detecting a navigation

Use request.mode === "navigate". It is true for top-level documents and for iframes; check request.destination (document versus iframe or frame) if they need different handling. Older code sniffed request.headers.get("Accept").includes("text/html"), which also matches fetch() calls that ask for HTML and misses navigations with unusual Accept headers; there is no reason to use it today.

What is special about navigations

  • Failure is visible. A network error on a navigation shows the browser's error page. Every navigation handler needs an offline fallback (see Offline UX & Fallbacks).
  • Redirect mode is manual. You can return opaqueredirect responses but not responses with redirected === true (see Redirects).
  • Opaque responses are never allowed, and the response determines the document's origin-level behavior: its CSP, COOP and COEP headers come from the headers you return. A cached HTML copy carries the headers it had when you cached it, so a CSP change on the server does not reach users served from an old cached page until you refresh that entry (see Content Security Policy).
  • Only GET navigations get navigation preload. A form submission (method: "POST", mode: "navigate") always arrives without a preload response.
  • Navigations trigger update checks. Every controlled navigation schedules a soft update after dispatch.
Site type Typical navigation strategy Why
Content site or server-rendered MPA Network first with a timeout, cached copy, then offline page Freshness matters; cached pages are a fallback.
SPA with an app shell Cache first: always answer with the precached shell The shell is versioned with the worker; data loads from APIs. See App Shell Model.
Hybrid (streamed shell + content) Stream cached header and footer around a network body Fast first paint and fresh content. See Streaming Responses.
sw.js - a robust network-first navigation handler
const PAGES = "pages";
const OFFLINE_URL = "/offline.html";

async function handleNavigation(event) {
  const { request } = event;
  try {
    // With navigation preload enabled this was already requested in parallel
    // with worker start-up; without it, preloadResponse resolves to undefined.
    const response = (await event.preloadResponse) ?? (await fetch(request));
    if (response.ok && response.type === "basic") {
      const copy = response.clone();
      event.waitUntil(
        caches.open(PAGES).then((cache) => cache.put(request, copy)).catch(console.warn),
      );
    }
    return response; // includes opaqueredirect and 4xx/5xx: let the page see them
  } catch {
    // Offline, DNS failure, or the preload request failed.
    const cache = await caches.open(PAGES);
    return (
      (await cache.match(request, { ignoreSearch: true })) ??
      (await caches.match(OFFLINE_URL)) ??
      new Response("You are offline.", {
        status: 503,
        headers: { "Content-Type": "text/plain; charset=utf-8" },
      })
    );
  }
}

self.addEventListener("fetch", (event) => {
  if (event.request.mode === "navigate" && event.request.method === "GET") {
    event.respondWith(handleNavigation(event));
  }
});

ignoreSearch: true on the fallback lookup means /article?utm_source=mail can be served from the cached /article when offline. Use it only for fallbacks; for normal lookups the query string usually matters.

Subresources by destination

Destination Typical strategy Notes
script, style with hashed filenames Cache first, never expires The URL changes when the content does.
script, style without hashes Stale-while-revalidate Avoid mixing versions: HTML and JS from different deploys break apps.
image Cache first with an entry limit Beware opaque third-party images and their quota padding.
font Cache first Font requests are cors requests, so responses are readable and cacheable.
audio, video Fall through, or serve cached files with range support See Range requests and media.
"" (fetch(), XHR) Route by URL: network first for APIs, cache first for static JSON Do not cache authenticated per-user responses without a plan to clear them on logout.
manifest Network first or stale-while-revalidate A stale manifest delays app identity updates (see App Identity & Updates).

The strategies themselves are covered in depth in Caching Strategies.

Routing patterns

A router answers one question synchronously: "for this request, which handler, if any?" The inputs are the URL, the method, the mode, the destination and the headers.

Matching on the URL

Always parse the URL and compare structured parts:

sw.js - URL-based matching
const url = new URL(event.request.url);

const isSameOrigin = url.origin === self.location.origin;
const isApi = isSameOrigin && url.pathname.startsWith("/api/");
const isHashedAsset = isSameOrigin && /^\/assets\/.+\.[0-9a-f]{8,}\.(js|css|woff2)$/.test(url.pathname);
const isThumbnail = url.hostname === "images.example-cdn.com" && url.searchParams.has("w");

Compare against self.location.origin rather than a hard-coded host so staging and production behave the same.

Matching with URLPattern

URLPattern gives you path-to-regexp style patterns with named groups, and it is available inside service workers. It shipped in Chrome 95, Firefox 142 and Safari 26, so every current engine has it, but keep a fallback if you support older Safari or Firefox releases.

sw.js - URLPattern routes
// With baseURL, protocol/hostname/port are fixed to your origin, and search and
// hash become wildcards because only the pathname was specified.
const articlePattern = new URLPattern({
  pathname: "/articles/:slug",
  baseURL: self.location.origin,
});

const result = articlePattern.exec(event.request.url);
if (result) {
  const { slug } = result.pathname.groups; // "/articles/pwa-routing" -> "pwa-routing"
}

// Cross-origin patterns list the hostname explicitly.
const cdnImages = new URLPattern({ hostname: "{*.}?example-cdn.com", pathname: "/img/*" });

A few URLPattern details matter for routers. test() and exec() accept a URL string or a URL object. Groups written as regular expressions (/:id(\\d+)) work in your own code, but patterns with regular-expression groups are rejected by the Static Routing API, which does not allow user-defined regular expressions; check pattern.hasRegExpGroups if you share patterns between the two. Pass { ignoreCase: true } as the options argument if your server treats paths case-insensitively.

Matching on destination, mode and method

sw.js - request-type predicates
const isNavigation = (r) => r.mode === "navigate";
const isRead = (r) => r.method === "GET" || r.method === "HEAD";
const isMedia = (r) => r.destination === "audio" || r.destination === "video";
const isRenderBlocking = (r) => r.destination === "style" || r.destination === "script";
const isOpaqueCandidate = (r, url) => r.mode === "no-cors" && url.origin !== self.location.origin;

HEAD requests deserve a mention: the Cache API only matches GET requests unless you pass ignoreMethod: true, so a HEAD request against a cache-first route silently misses. Most routers only handle GET and let everything else fall through.

First match wins

Order routes from most to least specific, and let the unmatched remainder fall through. A route table is easier to audit than nested conditionals, which is why Workbox's registerRoute() and the router at the end of this page are both lists evaluated in order.

Constructing responses

Everything you return is a Response, whether it came from the network, from Cache Storage or from your own code.

new Response(body, init)

Rule Behavior
init.status outside 200-599 RangeError. You cannot synthesize status 0 or a 1xx status.
Non-null body with status 101, 103, 204, 205 or 304 (null body status) TypeError. Use new Response(null, { status: 204 }).
init.statusText not a valid reason phrase TypeError.
Content-Type Set automatically from the body when you do not provide one: text/plain;charset=UTF-8 for strings, the blob's type for a Blob, multipart/form-data; boundary=... for FormData, application/x-www-form-urlencoded;charset=UTF-8 for URLSearchParams. Streams and buffers get none.
Set-Cookie / Set-Cookie2 Forbidden response-header names: silently dropped. A service worker cannot set cookies through a synthesized response.
response.type default, which the page treats like a same-origin response.
response.url Empty string; the page sees the request URL as the response URL.
sw.js - synthesized responses
// HTML, with headers the browser will honor as if the server sent them.
const html = new Response("<!doctype html><title>Maintenance</title><p>Back soon.</p>", {
  status: 503,
  statusText: "Service Unavailable",
  headers: {
    "Content-Type": "text/html; charset=utf-8",
    "Retry-After": "120",
    "Cache-Control": "no-store",
  },
});

// An empty success.
const noContent = new Response(null, { status: 204 });

// A small SVG placeholder for failed images.
const placeholder = new Response(
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 3"><rect width="4" height="3" fill="#ddd"/></svg>',
  { headers: { "Content-Type": "image/svg+xml" } },
);

Response.json()

Response.json(data, init) serializes data with JSON.stringify semantics and sets Content-Type: application/json unless you provide one. It shipped in Chrome 105, Firefox 115 and Safari 17. Two edge cases: values that serialize to undefined (for example Response.json(undefined) or a bare function) throw TypeError, and BigInt values throw TypeError just as JSON.stringify does.

sw.js - JSON responses with a fallback for older engines
function json(data, init = {}) {
  if (typeof Response.json === "function") return Response.json(data, init);
  const headers = new Headers(init.headers);
  if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
  return new Response(JSON.stringify(data), { ...init, headers });
}

// json({ error: "offline", retryAfter: 30 }, { status: 503 })

Response.redirect()

Response.redirect(url, status = 302) creates a response with a Location header and an immutable Headers object. The URL is parsed against the service worker's script URL (its API base URL), so relative paths such as "/login" resolve against your origin; an unparsable URL throws TypeError. The status must be 301, 302, 303, 307 or 308, otherwise RangeError.

What the browser does with it depends on the request:

  • Navigations follow it as a normal HTTP redirect, and the new URL produces a fresh fetch event. Use 303 after handling a form POST so the follow-up request is a GET.
  • Subresource requests with redirect mode follow also follow it, and because the redirect came from the worker rather than the network, the redirected request is dispatched to your worker again. A route that redirects /a to /a loops until the browser's 20-redirect limit turns it into a network error.
  • Requests with redirect mode error fail, and non-navigation requests with redirect mode manual (for example fetch(url, { redirect: "manual" })) receive an opaqueredirect response.

Response.error()

Response.error() returns a response of type error with status 0. Passing it to respondWith() gives the page a network error, the same thing a rejected promise would, but deliberately and without a console stack trace pointing at your code. It is the right "no fallback exists" answer for a failed subresource.

Cloning and consuming bodies

A body can be read once. response.clone() tees the body into two independent streams, and it must happen before either copy is read or passed to respondWith(), otherwise it throws TypeError. Cloning is not free: when one branch is read faster than the other, the browser buffers the difference in memory, so cloning a 200 MB video to cache it while the page streams it holds data in memory until the slower branch catches up.

sw.js - clone before returning
async function networkAndCache(event) {
  const response = await fetch(event.request);
  const copy = response.clone(); // synchronous, before anything reads the body
  event.waitUntil(
    caches.open("runtime").then((cache) => cache.put(event.request, copy)).catch(console.warn),
  );
  return response; // the page reads this branch, the cache reads the other
}

Copying a response to change status or headers

Response headers from the network are immutable, so changing them means building a new Response around the same body:

sw.js - copy with extra headers
function withHeaders(response, extra) {
  if (response.type === "opaque" || response.type === "opaqueredirect") {
    return response; // status 0 cannot be copied: new Response() would throw RangeError
  }
  const headers = new Headers(response.headers);
  for (const [name, value] of Object.entries(extra)) headers.set(name, value);
  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers,
  });
}

// During development, label every response with the route that produced it.
// return withHeaders(response, { "X-SW-Route": "api-network-first" });

The copy loses response.url and response.redirected, which is exactly why the same technique fixes redirected responses for navigations.

Opaque responses

A no-cors request to another origin that does not go through CORS produces an opaque response. It is the one response type your worker can hold but not inspect.

Property Opaque response value
type "opaque"
status 0 (the real status is hidden)
ok false
statusText ""
headers Empty
body null; text(), blob() and friends resolve to empty values
url ""

Where opaque responses are allowed

Only as the answer to a request whose mode is no-cors and which is not a navigation or worker script. The page's element (an <img>, a classic <script>) can still use the bytes, because the browser, not script, consumes them. Anything else is a network error, and when the page is cross-origin isolated (COEP require-corp), Chromium also runs the Cross-Origin-Resource-Policy check before handing an opaque response to the page.

Why caching them is risky

  • You cannot tell success from failure. A 404 page, a 500 error or a captive-portal login page all look identical: status 0. Cache one with a cache-first strategy and you serve the error forever.
  • Quota padding. To avoid leaking cross-origin response sizes through navigator.storage.estimate(), browsers pad the size they account for opaque entries. Chromium adds a pseudo-random padding between 0 and about 14 MiB to each opaque response, about 7 MiB on average, however small the response is (older Workbox documentation describes this as a fixed "7 megabytes" minimum, which no longer matches the implementation). Two hundred cached third-party thumbnails can therefore account for over a gigabyte of quota in Chrome. The padding is implementation-defined, so measure in each engine you target (see Storage Quotas & Persistence).
  • Security headers are invisible, so you cannot honor the origin's Cache-Control: no-store or Vary.

How to avoid opaque responses

The durable fix is to make the request a CORS request. Add crossorigin="anonymous" to <img>, <script>, <link> and <video> elements that load cacheable third-party resources, and have the other origin send Access-Control-Allow-Origin. The response becomes type cors: readable status, readable headers, exact quota accounting. If you control neither the markup nor the server, the Workbox guidance is to use network-first or stale-while-revalidate for opaque responses, so a bad entry is replaced on the next successful request, and to opt in to caching them explicitly (Workbox's CacheableResponsePlugin with statuses: [0, 200]) only with an expiration limit.

sw.js - an explicit, bounded policy for opaque images
const MAX_OPAQUE_ENTRIES = 20; // ~140 MB of padded quota in Chrome

async function thirdPartyImage(event) {
  const cache = await caches.open("third-party-images");
  const cached = await cache.match(event.request);

  const network = fetch(event.request).then((response) => {
    // Status 0 is all we get; cache it, but keep the cache small. The write runs
    // in the background so a slow or failing cache.put() (QuotaExceededError is
    // likely with padded entries) never delays or breaks the image itself.
    if (response.type === "opaque" || response.ok) {
      const copy = response.clone(); // before the page starts reading the body
      event.waitUntil(
        (async () => {
          await cache.put(event.request, copy);
          const keys = await cache.keys(); // insertion order: oldest first
          for (const key of keys.slice(0, Math.max(0, keys.length - MAX_OPAQUE_ENTRIES))) {
            await cache.delete(key);
          }
        })().catch((error) => console.warn("[sw] opaque image cache write failed", error)),
      );
    }
    return response;
  });
  event.waitUntil(network.catch(() => {})); // keep the revalidation alive on cache hits

  return cached ?? network; // stale-while-revalidate: a bad entry lives one visit at most
}

CORS inside the service worker

Your worker runs in your origin, so every fetch() it makes is a request from your origin: same-origin requests need nothing special, and cross-origin requests follow the normal CORS protocol, including preflights for non-safelisted methods and headers. The response type your worker receives, basic, cors or opaque, is then checked against the page's original request mode as described in Everything that becomes a network error.

You can upgrade a no-cors request to a CORS request when you know the other origin supports CORS, which turns an opaque response into a readable one:

sw.js - upgrade no-cors image requests to CORS
async function corsFirst(event) {
  const { request } = event;
  if (request.mode !== "no-cors") return fetch(request);

  try {
    // credentials: "omit" matches crossorigin="anonymous" semantics.
    const corsRequest = new Request(request.url, { mode: "cors", credentials: "omit" });
    return await fetch(corsRequest); // type "cors": readable and exactly accounted
  } catch {
    // No Access-Control-Allow-Origin (or offline): fall back to the original
    // request, which yields an opaque response the element can still use.
    return fetch(request);
  }
}

Returning a cors response to a no-cors request is allowed. The cost of this pattern is a second request whenever the server does not support CORS, so apply it only to origins you know answer with CORS headers, and remember that credentials: "omit" drops cookies the original request would have sent.

Range requests and media

<video> and <audio> elements request media in pieces with a Range header (Range: bytes=0-, then specific windows as the user seeks), and expect 206 Partial Content answers with a Content-Range header. The Cache API was not designed for this:

  • cache.put() rejects a 206 response with a TypeError, and cache.add() and cache.addAll() reject one too.
  • Cache matching ignores the Range header. A lookup for a range request returns the full 200 entry.
  • Rebuilding a no-cors media request with a non-empty init strips Range, because it is the only privileged no-CORS request-header in the Fetch standard.
  • An opaque 206 can only answer a request that has a Range header. The Fetch standard's range-requested flag turns it into a network error otherwise, which blocks attacks that splice partial cross-origin responses into APIs that never asked for a range.
  • Engines differ in strictness. Answering a media range request with a full 200 works in some engines and not others; Safari is known to refuse to play video from a service worker unless it gets proper 206 responses, as Phil Nash documented in "Service workers: beware Safari's range request". Always answer range requests with real 206 responses.

So the practical rules are:

  1. Uncached media: fall through. Do not call respondWith() for range requests you are not serving from cache. The browser talks to the server directly with the original Range header. (web.dev reported that Chrome and Edge 87+ and recent Safari forward Range correctly on fetch(event.request), while Firefox dropped it as of October 2020; falling through sidesteps the question.)
  2. Cached media: build the 206 yourself from the cached full response.
  3. Cache media with a full, non-range request (cache.add("/media/intro.mp4") sends no Range header) and make sure it is same-origin or CORS: an opaque cached entry has no readable body to slice.
sw.js - serving range requests from a cached full response
/** Parse one "bytes=" range. Returns {start, end}, "unsatisfiable", or null (ignore Range). */
function parseRange(header, size) {
  if (!header) return null; // no Range header: serve the full body
  const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
  if (!match) return null; // multiple ranges or another unit: serve the full body
  const [, first, last] = match;
  if (first === "" && last === "") return null;
  if (first === "") {
    // Suffix range: the last N bytes.
    const suffix = Number(last);
    if (suffix === 0) return "unsatisfiable";
    return { start: Math.max(size - suffix, 0), end: size - 1 };
  }
  const start = Number(first);
  if (last !== "" && Number(last) < start) return null; // invalid range-spec: ignore it
  if (start >= size) return "unsatisfiable";
  const end = last === "" ? size - 1 : Math.min(Number(last), size - 1);
  return { start, end };
}

async function rangeFromCache(event, cacheName) {
  const { request } = event;
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request); // Range is ignored when matching
  if (!cached) return fetch(request);

  const blob = await cached.blob();
  const range = parseRange(request.headers.get("range"), blob.size);
  const headers = new Headers(cached.headers);
  headers.set("Accept-Ranges", "bytes");

  if (range === null) {
    headers.set("Content-Length", String(blob.size));
    return new Response(blob, { status: 200, headers });
  }
  if (range === "unsatisfiable") {
    return new Response(null, {
      status: 416,
      statusText: "Range Not Satisfiable",
      headers: { "Content-Range": `bytes */${blob.size}` },
    });
  }
  const slice = blob.slice(range.start, range.end + 1); // end is inclusive in HTTP
  headers.set("Content-Range", `bytes ${range.start}-${range.end}/${blob.size}`);
  headers.set("Content-Length", String(slice.size));
  return new Response(slice, { status: 206, statusText: "Partial Content", headers });
}

The parsing follows RFC 9110: a suffix range (bytes=-500) means the last 500 bytes, an open range (bytes=1000-) runs to the end, an end position beyond the file is clamped, a start position at or beyond the end is 416 with Content-Range: bytes */<size>, and a syntactically invalid range is ignored in favor of a full 200. Multi-range requests (bytes=0-99,200-299) would require a multipart/byteranges body; media elements do not send them, so serving the full resource is the pragmatic answer. If you use Workbox, the workbox-range-requests module implements the same logic as a plugin (see Advanced Workbox).

POST requests and request bodies

Writes need different handling from reads, and the constraints come from both the Cache API and the one-shot nature of bodies.

What you cannot do with non-GET requests

  • cache.put() and cache.add() reject any request whose method is not GET with a TypeError.
  • cache.match() ignores non-GET requests (returns undefined) unless you pass ignoreMethod: true, in which case the method is simply not compared.
  • Navigation preload only runs for GET navigations.

So a service worker cannot "cache a POST". It can store the request's data somewhere else (IndexedDB) and replay it, or answer with a synthesized response.

Bodies are read once

event.request has a body stream that can be consumed exactly once. fetch(event.request) consumes it; so do await event.request.json() and new Request(event.request, init). If you need the body and need to forward the request, clone first:

sw.js - read and forward
self.addEventListener("fetch", (event) => {
  if (event.request.method !== "POST") return;
  event.respondWith(
    (async () => {
      const forLogging = event.request.clone(); // clone BEFORE fetch() consumes the body
      const response = await fetch(event.request);
      event.waitUntil(
        forLogging
          .text()
          .then((body) => audit(event.request.url, body))
          .catch((error) => console.warn("[sw] audit failed", error)), // never affects the response
      );
      return response;
    })(),
  );
});

async function audit(url, body) {
  // Keep a small, size-bounded diagnostic trail. Never log secrets: redact or
  // skip endpoints that carry credentials or personal data.
  const cache = await caches.open("post-audit");
  await cache.put(
    `/__audit/${Date.now()}`,
    new Response(JSON.stringify({ url, bytes: body.length, at: new Date().toISOString() }), {
      headers: { "Content-Type": "application/json" },
    }),
  );
}

Forgetting the clone produces TypeError: Failed to execute 'fetch' on 'ServiceWorkerGlobalScope': Cannot construct a Request with a Request object that has already been used. in Chrome. Portability note: read bodies with arrayBuffer(), blob(), text(), json() or formData(). The request.body stream getter exists in Chromium (105+) and Safari but not in stable Firefox, and streaming uploads (fetch(url, { body: stream, duplex: "half" })) are Chromium-only.

An offline outbox for API writes

The classic pattern: try the network; on a network failure, store the request in IndexedDB, answer 202 Accepted, and replay later.

sw.js - queue failed POSTs and replay them
const OUTBOX_DB = "outbox";
const OUTBOX_STORE = "requests";

function openOutbox() {
  return new Promise((resolve, reject) => {
    const open = indexedDB.open(OUTBOX_DB, 1);
    open.onupgradeneeded = () => {
      open.result.createObjectStore(OUTBOX_STORE, { keyPath: "id", autoIncrement: true });
    };
    open.onsuccess = () => resolve(open.result);
    open.onerror = () => reject(open.error);
  });
}

function txDone(tx) {
  return new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
    tx.onabort = () => reject(tx.error);
  });
}

async function enqueue(request) {
  const entry = {
    url: request.url,
    method: request.method,
    headers: [...request.headers], // plain arrays are structured-cloneable
    body: await request.arrayBuffer(),
    queuedAt: Date.now(),
  };
  const db = await openOutbox();
  const tx = db.transaction(OUTBOX_STORE, "readwrite");
  tx.objectStore(OUTBOX_STORE).add(entry);
  await txDone(tx);
  db.close();
}

async function handleApiWrite(event) {
  const backup = event.request.clone(); // fetch() below consumes the original body
  try {
    return await fetch(event.request); // 4xx/5xx resolve normally and reach the page
  } catch {
    // TypeError: offline, DNS or TLS failure. Queue and acknowledge.
    await enqueue(backup);
    if ("sync" in self.registration) {
      await self.registration.sync.register("outbox").catch(() => {});
    }
    return new Response(JSON.stringify({ queued: true }), {
      status: 202,
      headers: { "Content-Type": "application/json" },
    });
  }
}

async function replayOutbox() {
  const db = await openOutbox();
  const entries = await new Promise((resolve, reject) => {
    const req = db.transaction(OUTBOX_STORE).objectStore(OUTBOX_STORE).getAll();
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
  try {
    for (const entry of entries) {
      // Throws while still offline: the sync event fails and will be retried.
      const response = await fetch(entry.url, {
        method: entry.method,
        headers: entry.headers,
        body: entry.body,
      });
      if (response.status >= 500) throw new Error(`Server error ${response.status}`);
      // 2xx done; 4xx will never succeed, so drop it (and tell the user in real code).
      const tx = db.transaction(OUTBOX_STORE, "readwrite");
      tx.objectStore(OUTBOX_STORE).delete(entry.id);
      await txDone(tx);
    }
  } finally {
    db.close();
  }
}

self.addEventListener("fetch", (event) => {
  const { request } = event;
  const url = new URL(request.url);
  if (request.method === "POST" && url.origin === self.location.origin && url.pathname.startsWith("/api/")) {
    event.respondWith(handleApiWrite(event));
  }
});

self.addEventListener("sync", (event) => {
  if (event.tag === "outbox") event.waitUntil(replayOutbox());
});

Background Sync is Chromium-only, so production code also replays when a page reports it is back online (see Background Sync and Offline-First Data & Sync). Only queue idempotent operations, or include an idempotency key header so a replay after an ambiguous failure does not create duplicates.

Form navigations and share targets

A <form method="post"> submission is a navigation with method: "POST". When you handle it in the worker, answer with a 303 redirect so the browser ends up on a GET URL and a reload does not resubmit:

sw.js - Post/Redirect/Get from the worker
async function handleFormPost(event) {
  try {
    const response = await fetch(event.request.clone()); // server handles it normally
    return response; // typically already a 303 from the server
  } catch {
    await enqueue(event.request);
    return Response.redirect("/submitted?queued=1", 303);
  }
}

The same shape handles a manifest share_target that uses POST: the worker reads await event.request.formData(), stores the shared files, and redirects to a page that displays them (see Web Share Target).

Streaming responses

The body you return does not have to exist yet. If you respond with a Response whose body is a ReadableStream, the browser forwards bytes to the page as you enqueue them, and HTML starts parsing and rendering before the stream finishes. The spec pipes your stream into a new one owned by the browser and notes that the page will read "the same data that was written, but it may be chunked differently".

sw.js - stream a cached header, a network body and a cached footer
async function streamedArticle(event) {
  const url = new URL(event.request.url);
  const header = caches.match("/partials/header.html");
  const body = fetch(`/partials${url.pathname}.html`).catch(() =>
    caches.match("/partials/offline-body.html"),
  );
  const footer = caches.match("/partials/footer.html");

  const { readable, writable } = new TransformStream();

  const done = (async () => {
    try {
      for (const part of [header, body, footer]) {
        const response = await part;
        if (!response?.body) continue;
        // preventClose keeps the writable open for the next part.
        await response.body.pipeTo(writable, { preventClose: true });
      }
      await writable.close();
    } catch (error) {
      await writable.abort(error).catch(() => {});
    }
  })();

  // Keep the worker alive until the last byte has been written.
  event.waitUntil(done);

  return new Response(readable, {
    headers: { "Content-Type": "text/html; charset=utf-8" },
  });
}

Three constraints to design around: the status and headers are committed when you return the Response, so a failure halfway through can only truncate or error the body; errors after that point surface in the page as a failed or incomplete load, not as your offline page; and the worker must stay alive until the stream is done, hence event.waitUntil(done). The full treatment, including composing streams with navigation preload and TextEncoderStream for generated markup, is on Streaming Responses.

Redirects

Redirects are where service worker code most often breaks navigations, because the rules differ by request type and a Response remembers whether it was redirected.

response.redirected and the URL list

Every response has a URL list: the request URL plus one entry per redirect followed. response.redirected is true when that list has more than one entry, and response.url is its last entry. fetch() with the default redirect mode follow produces such responses; so do cache.add() and cache.addAll(), which fetch with follow, and Cache Storage stores the URL list along with the response.

Why redirected responses break navigations

The Fetch standard turns a service worker response into a network error when the request's redirect mode is not follow and the response's URL list has more than one item. Navigations always use manual. The classic failure:

  1. You precache /about, and the server redirects it to /about/.
  2. cache.addAll() follows the redirect and stores a response with redirected === true.
  3. A user navigates to /about; your cache-first handler returns that entry.
  4. Chrome logs The FetchEvent for "https://example.com/about" resulted in a network error response: a redirected response was used for a request whose redirect mode is not "follow". and the user sees an error page.

The rule exists for security: letting a worker answer a navigation with a response that was redirected elsewhere would let it present content from one URL under another. The fix is to store a clean copy whose URL list is empty:

sw.js - clean redirected responses before caching them for navigations
function cleanResponse(response) {
  if (!response.redirected) return response;
  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers: response.headers,
  });
}

self.addEventListener("install", (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open("pages-v3");
      for (const url of ["/", "/about", "/offline.html"]) {
        const request = new Request(url, { cache: "reload" });
        const response = await fetch(request);
        if (!response.ok) throw new Error(`${url} -> ${response.status}`);
        await cache.put(request, cleanResponse(response));
      }
    })(),
  );
});

Workbox exposes the same idea as copyResponse() in workbox-core. Better still, precache the final URLs (/about/) so there is nothing to clean.

fetch() for a navigation returns opaqueredirect

Because a navigation's redirect mode is manual, fetch(event.request) does not follow a server redirect. You get a response of type opaqueredirect: status 0, no headers, no body. Pass it to respondWith() unchanged and the browser performs the redirect, and the target URL produces a new fetch event. Chromium's navigation preload does the same: when the preload request is redirected, event.preloadResponse resolves to an opaqueredirect response. Two consequences:

  • Do not treat !response.ok as failure for navigations. An opaqueredirect has ok === false; if you then serve a cached page instead, you silently break every redirect on your site, including logins.
  • Do not cache it. It has no body and its target is invisible to you; a cached one replays a stale redirect.

Redirects you synthesize

Response.redirect() responses are redirect responses, not redirected responses: their URL list is empty, so they satisfy navigations. As described in Response.redirect(), the browser follows them, and for subresources the follow-up request is dispatched to your worker again (network redirects are not re-dispatched). Synthesized redirects are useful for:

  • Sending offline users of a deep link to a cached equivalent: Response.redirect("/offline/article", 302).
  • Normalizing URLs (/index.html to /) without a server round trip.
  • Finishing a form POST with 303 See Other.

The final URL of a subresource

Per the spec, when you answer a subresource request with a Response whose url differs from the request URL (for example, you fetched /v2/app.css to answer a request for /app.css), that URL becomes the resource's final URL: CSS @import and url() references and a worker's importScripts() resolve against it. MDN's compatibility data shows Firefox implementing this (since 59) and Chrome and Safari still using the request URL. Navigations never adopt the response URL, which is what lets you serve an offline page at the URL the user asked for. Avoid depending on either behavior: serve assets under their real URLs.

Error handling

A fetch handler meets four kinds of failure, and each needs a different response.

Failure How it surfaces Typical answer
Network unavailable, DNS or TLS error, CORS failure fetch() rejects with TypeError Cached copy, then an offline fallback
HTTP error (404, 500, 503) fetch() resolves; response.ok === false Usually pass it through; do not cache it
Timeout or abort fetch() rejects with an AbortError (or your signal's reason) Cached copy if the network is too slow
Storage problems cache.put() rejects (QuotaExceededError, TypeError for 206, non-GET, Vary: * or a used body) Log and continue; never fail the response because a cache write failed
Bugs in your code Any exception inside the promise A catch-all fallback so the page still gets a response

Timeouts that do not break navigations

A network-first strategy needs a timeout, or "lie-fi" (a connection that is technically up but not delivering) keeps users staring at a blank page. Two details matter. First, racing a timer against the network is usually better than aborting the request: if no cached copy exists, you still want the network answer when it arrives. Second, if you do abort, remember that constructing new Request(event.request, { signal }) turns a navigation into a same-origin request; pass the signal to fetch() only for non-navigation requests:

sw.js - abortable fetch with a timeout for subresources
async function fetchWithTimeout(request, ms) {
  // A non-empty init turns "navigate" into "same-origin" and strips Range from
  // no-cors requests, so leave those requests untouched.
  if (request.mode === "navigate" || request.headers.has("range")) return fetch(request);

  const controller = new AbortController();
  const timer = setTimeout(
    () => controller.abort(new DOMException(`No response within ${ms} ms`, "TimeoutError")),
    ms,
  );
  // If the page gives up (where engines wire up request.signal), give up too.
  const onPageAbort = () => controller.abort(request.signal.reason);
  request.signal?.addEventListener("abort", onPageAbort, { once: true });
  try {
    return await fetch(request, { signal: controller.signal });
  } finally {
    clearTimeout(timer);
    request.signal?.removeEventListener("abort", onPageAbort);
  }
}

AbortSignal.timeout() and AbortSignal.any() express the same thing more compactly, but they arrived in different engines at different times (MDN lists AbortSignal.any() for Chrome 116, Firefox 124 and Safari 17.4), so the manual version is the portable one.

Fallbacks by destination

The fallback has to be something the requesting context can use. An HTML offline page is right for a navigation and useless for an image or a fetch() expecting JSON:

Destination Fallback Status
document, iframe Cached copy, then a precached offline page Cached page's status, or 200/503 for the offline page
image A precached placeholder SVG 200
font Nothing; the browser uses the fallback font Network error (Response.error())
script, style Nothing that could run safely Network error
"" with Accept: application/json {"error":"offline"} 503 with Retry-After
audio, video Nothing; the element fires error Network error

Serving the offline page with status 200 makes it look like a successful load to your analytics and to any code that checks the status; 503 is more honest and lets real-user monitoring separate offline views. Choose deliberately, and see Offline UX & Fallbacks for designing the page itself.

A complete small router

The file below puts the page together: synchronous route matching with URLPattern (and a fallback for engines without it), network-first navigations that use navigation preload, cache-first hashed assets and images with an entry limit, network-first API reads with a timeout, range support for cached media, redirect cleaning during precaching, and destination-aware offline fallbacks. It has no dependencies and is roughly 400 lines including comments.

sw.js
// sw.js - a small, dependency-free fetch router for a PWA.
// Routes are matched synchronously (respondWith() must be called during
// dispatch); handlers are async and always resolve with a Response.

const VERSION = "2026-09-25";
const CACHES = {
  shell: `shell-${VERSION}`,
  assets: `assets-${VERSION}`,
  pages: "pages",
  api: "api",
  images: "images",
  media: "media",
  fonts: "fonts",
};
const OFFLINE_PAGE = "/offline.html";
const OFFLINE_IMAGE = "/img/offline.svg";
const PRECACHE_URLS = ["/", OFFLINE_PAGE, OFFLINE_IMAGE, "/css/app.css", "/js/app.js"];

const noop = () => {};

// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------

class Router {
  #routes = [];
  #catchHandler = null;

  /**
   * @param {string} name      Used in logs.
   * @param {(ctx: RouteContext) => boolean} match  MUST be synchronous.
   * @param {(ctx: RouteContext) => Promise<Response>} handler
   */
  add(name, match, handler) {
    this.#routes.push({ name, match, handler });
    return this;
  }

  /** Runs when a handler throws or rejects (offline, quota, bugs...). */
  setCatchHandler(handler) {
    this.#catchHandler = handler;
    return this;
  }

  /**
   * Returns a Promise<Response> for the first matching route, or null when
   * no route matches (the caller then does NOT call respondWith()).
   */
  handle(event) {
    const { request } = event;
    const url = new URL(request.url);
    for (const route of this.#routes) {
      const ctx = { event, request, url, params: {} };
      if (route.match(ctx)) return this.#run(route, ctx);
    }
    return null; // (1)!
  }

  async #run(route, ctx) {
    try {
      const response = await route.handler(ctx);
      if (!(response instanceof Response)) {
        throw new TypeError(`Route "${route.name}" did not return a Response`);
      }
      return response;
    } catch (error) {
      console.warn(`[sw] route "${route.name}" failed for ${ctx.request.url}`, error);
      if (this.#catchHandler) {
        try {
          return await this.#catchHandler(ctx, error);
        } catch (fallbackError) {
          console.error("[sw] catch handler failed", fallbackError);
        }
      }
      return Response.error(); // (2)!
    }
  }
}

// ---------------------------------------------------------------------------
// Matchers (all synchronous)
// ---------------------------------------------------------------------------

/** Compile "/posts/:id/*" into a matcher; uses URLPattern when available. */
function path(pattern, { sameOrigin = true } = {}) {
  if (typeof URLPattern === "function") {
    const compiled = sameOrigin
      ? new URLPattern({ pathname: pattern, baseURL: self.location.origin })
      : new URLPattern({ pathname: pattern });
    return (ctx) => {
      const result = compiled.exec(ctx.url.href);
      if (!result) return false;
      ctx.params = result.pathname.groups;
      return true;
    };
  }
  // Fallback for engines without URLPattern: ":name" segments and "*".
  const source = pattern
    .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
    .replace(/:([A-Za-z_]\w*)/g, "(?<$1>[^/]+)")
    .replace(/\*/g, ".*");
  const regex = new RegExp(`^${source}$`);
  return (ctx) => {
    if (sameOrigin && ctx.url.origin !== self.location.origin) return false;
    const result = regex.exec(ctx.url.pathname);
    if (!result) return false;
    ctx.params = { ...result.groups };
    return true;
  };
}

const all = (...matchers) => (ctx) => matchers.every((m) => m(ctx));
const method = (name) => ({ request }) => request.method === name;
const destination = (...names) => ({ request }) => names.includes(request.destination);
const isNavigation = ({ request }) => request.mode === "navigate" && request.method === "GET";
const hasRange = ({ request }) => request.headers.has("range");
const origin = (value) => ({ url }) => url.origin === value;
const isPrecached = ({ url }) =>
  url.origin === self.location.origin && PRECACHE_URLS.includes(url.pathname);

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function isCacheable(response, { allowOpaque = false } = {}) {
  if (!response) return false;
  if (response.type === "opaque") return allowOpaque;
  if (!response.ok || response.status === 206) return false;
  const cacheControl = response.headers.get("Cache-Control") || "";
  return !/\bno-store\b/i.test(cacheControl);
}

function reportCacheError(error) {
  // QuotaExceededError is the one you will actually see in production.
  console.warn("[sw] cache write failed", error);
}

function json(data, init = {}) {
  if (typeof Response.json === "function") return Response.json(data, init);
  const headers = new Headers(init.headers);
  if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
  return new Response(JSON.stringify(data), { ...init, headers });
}

/** Copy a response so its URL list is empty (response.redirected === false). */
function cleanResponse(response) {
  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers: response.headers,
  });
}

async function trimCache(cache, maxEntries) {
  const keys = await cache.keys(); // oldest first: the spec keeps insertion order
  for (const request of keys.slice(0, Math.max(0, keys.length - maxEntries))) {
    await cache.delete(request);
  }
}

// ---------------------------------------------------------------------------
// Strategies
// ---------------------------------------------------------------------------

const TIMED_OUT = Symbol("timed out");

/**
 * Network first with a timeout. If the network is slower than timeoutMs and a
 * cached copy exists, serve the cache; otherwise keep waiting for the network.
 */
async function networkFirst(ctx, { cacheName, timeoutMs = 4000, fetcher }) {
  const { event, request } = ctx;
  const cache = await caches.open(cacheName);

  const network = (fetcher ? fetcher(ctx) : fetch(request)).then((response) => {
    if (isCacheable(response)) {
      event.waitUntil(cache.put(request, response.clone()).catch(reportCacheError));
    }
    return response;
  });
  // Keep the worker alive until the network settles, even if the cache wins.
  event.waitUntil(network.then(noop, noop)); // (3)!

  let timer;
  const timeout = new Promise((resolve) => {
    timer = setTimeout(resolve, timeoutMs, TIMED_OUT);
  });

  try {
    const winner = await Promise.race([network, timeout]);
    if (winner !== TIMED_OUT) return winner;
    return (await cache.match(request)) ?? (await network);
  } catch (error) {
    const cached = await cache.match(request);
    if (cached) return cached;
    throw error; // let the router's catch handler build the fallback
  } finally {
    clearTimeout(timer);
  }
}

async function cacheFirst(ctx, { cacheName, maxEntries, allowOpaque = false }) {
  const { event, request } = ctx;
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);
  if (cached) return cached;

  const response = await fetch(request);
  if (isCacheable(response, { allowOpaque })) {
    const copy = response.clone(); // clone BEFORE the body reaches the page
    event.waitUntil(
      cache
        .put(request, copy)
        .then(() => (maxEntries ? trimCache(cache, maxEntries) : undefined))
        .catch(reportCacheError),
    );
  }
  return response;
}

async function staleWhileRevalidate(ctx, { cacheName }) {
  const { event, request } = ctx;
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);

  const network = fetch(request).then((response) => {
    if (isCacheable(response)) {
      event.waitUntil(cache.put(request, response.clone()).catch(reportCacheError));
    }
    return response;
  });
  event.waitUntil(network.then(noop, noop));

  return cached ?? network;
}

// ---------------------------------------------------------------------------
// Range requests for cached media
// ---------------------------------------------------------------------------

/** Parse a single "bytes=" range. Returns {start,end}, "unsatisfiable" or null (ignore). */
function parseRange(header, size) {
  if (!header) return null; // no Range header: serve the full body
  const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
  if (!match) return null; // multiple ranges or another unit: serve the full body
  const [, first, last] = match;
  if (first === "" && last === "") return null;
  if (first === "") {
    const suffix = Number(last);
    if (suffix === 0) return "unsatisfiable";
    return { start: Math.max(size - suffix, 0), end: size - 1 };
  }
  const start = Number(first);
  if (last !== "" && Number(last) < start) return null; // invalid spec: ignore Range
  if (start >= size) return "unsatisfiable";
  const end = last === "" ? size - 1 : Math.min(Number(last), size - 1);
  return { start, end };
}

async function rangeFromCache(ctx, { cacheName }) {
  const { request } = ctx;
  const cache = await caches.open(cacheName);
  // Cache matching ignores the Range header, so this finds the full 200 entry.
  const cached = await cache.match(request);
  if (!cached) return fetch(request); // Range header is forwarded unchanged

  const blob = await cached.blob(); // empty for opaque entries - cache media with CORS
  const range = parseRange(request.headers.get("range"), blob.size);
  const headers = new Headers(cached.headers);
  headers.set("Accept-Ranges", "bytes");

  if (range === null) {
    headers.set("Content-Length", String(blob.size));
    return new Response(blob, { status: 200, headers });
  }
  if (range === "unsatisfiable") {
    return new Response(null, {
      status: 416,
      statusText: "Range Not Satisfiable",
      headers: { "Content-Range": `bytes */${blob.size}` },
    });
  }
  const slice = blob.slice(range.start, range.end + 1);
  headers.set("Content-Range", `bytes ${range.start}-${range.end}/${blob.size}`);
  headers.set("Content-Length", String(slice.size));
  return new Response(slice, { status: 206, statusText: "Partial Content", headers });
}

// ---------------------------------------------------------------------------
// Navigation handler (uses navigation preload when enabled)
// ---------------------------------------------------------------------------

function navigationFetcher({ event, request }) {
  // preloadResponse resolves to undefined when preload is off or unsupported,
  // and rejects with a TypeError when the preload hit a network error.
  return Promise.resolve(event.preloadResponse).then((preloaded) => preloaded ?? fetch(request)); // (4)!
}

// ---------------------------------------------------------------------------
// Offline fallbacks by destination
// ---------------------------------------------------------------------------

async function offlineFallback({ request }) {
  switch (request.destination) {
    case "document":
    case "iframe":
    case "frame": {
      const page = await caches.match(OFFLINE_PAGE);
      return (
        page ??
        new Response("<!doctype html><title>Offline</title><h1>You are offline</h1>", {
          status: 503,
          headers: { "Content-Type": "text/html; charset=utf-8" },
        })
      );
    }
    case "image":
      return (await caches.match(OFFLINE_IMAGE)) ?? Response.error();
    case "":
      // fetch()/XHR from the page: answer in a shape the app can handle.
      if ((request.headers.get("Accept") || "").includes("json")) {
        return json({ error: "offline" }, { status: 503, headers: { "Retry-After": "30" } });
      }
      return Response.error();
    default:
      return Response.error();
  }
}

// ---------------------------------------------------------------------------
// Route table - first match wins, so order from most to least specific.
// ---------------------------------------------------------------------------

const router = new Router()
  .add("navigations", isNavigation, (ctx) =>
    networkFirst(ctx, { cacheName: CACHES.pages, timeoutMs: 4000, fetcher: navigationFetcher }),
  )
  .add("precached shell", all(method("GET"), isPrecached), (ctx) =>
    cacheFirst(ctx, { cacheName: CACHES.shell }),
  )
  .add("hashed assets", all(method("GET"), path("/assets/*")), (ctx) =>
    cacheFirst(ctx, { cacheName: CACHES.assets }),
  )
  .add("cached media", all(method("GET"), hasRange, destination("audio", "video"), path("/media/*")), (ctx) =>
    rangeFromCache(ctx, { cacheName: CACHES.media }),
  )
  .add("images", all(method("GET"), destination("image"), path("/*")), (ctx) =>
    cacheFirst(ctx, { cacheName: CACHES.images, maxEntries: 200 }),
  )
  .add("api reads", all(method("GET"), path("/api/*")), (ctx) =>
    networkFirst(ctx, { cacheName: CACHES.api, timeoutMs: 3000 }),
  )
  .add("web fonts", all(method("GET"), destination("font"), origin("https://fonts.gstatic.com")), (ctx) =>
    cacheFirst(ctx, { cacheName: CACHES.fonts, maxEntries: 30 }),
  )
  .add("stylesheets", all(method("GET"), destination("style")), (ctx) =>
    staleWhileRevalidate(ctx, { cacheName: CACHES.assets }),
  )
  .setCatchHandler(offlineFallback);

// ---------------------------------------------------------------------------
// Event wiring - listeners must be added during the initial script evaluation.
// ---------------------------------------------------------------------------

self.addEventListener("fetch", (event) => {
  const { request } = event;
  // fetch() would throw for this combination; let the browser handle it.
  if (request.cache === "only-if-cached" && request.mode !== "same-origin") return;

  const responsePromise = router.handle(event);
  if (responsePromise) {
    event.respondWith(responsePromise); // (5)!
  }
  // No match: return without calling respondWith() and the browser goes to
  // the network as if this worker did not exist.
});

self.addEventListener("install", (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(CACHES.shell);
      await Promise.all(
        PRECACHE_URLS.map(async (url) => {
          const request = new Request(url, { cache: "reload" }); // bypass the HTTP cache
          const response = await fetch(request);
          if (!response.ok) throw new Error(`Precache of ${url} failed: ${response.status}`);
          // A redirected response cannot satisfy a navigation (redirect mode "manual").
          await cache.put(request, response.redirected ? cleanResponse(response) : response); // (6)!
        }),
      );
    })(),
  );
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      if (self.registration.navigationPreload) {
        await self.registration.navigationPreload.enable();
      }
      const keep = new Set(Object.values(CACHES));
      const names = await caches.keys();
      await Promise.all(names.filter((name) => !keep.has(name)).map((name) => caches.delete(name)));
    })(),
  );
});
  1. Returning null synchronously is how the router says "no route": the listener then does not call respondWith() and the request falls through to the network untouched.
  2. The catch handler runs for every failed route, so the promise given to respondWith() never rejects. Response.error() is the deliberate last resort for subresources with no sensible fallback.
  3. Registering the network promise as a lifetime promise keeps the worker alive for the background cache update when the timeout serves the cached copy first. It also avoids Chromium's "navigation preload request was cancelled" warning, because the preload promise is part of network.
  4. Promise.resolve() makes the code work in engines where event.preloadResponse does not exist (it is then undefined). A rejected preload (network failure) rejects network, which sends the strategy to its cached copy and then to the offline fallback.
  5. respondWith() is called synchronously during dispatch with a promise. Everything asynchronous happens inside that promise.
  6. cache.put() stores the URL list, so a redirected response cached here would later fail as the answer to a navigation. The clean copy has an empty URL list.

How the route table behaves:

Route Matches Strategy Cache When offline
navigations GET with mode navigate Network first (preload), 4 s timeout pages Cached page, then /offline.html
precached shell /, /offline.html, app CSS and JS Cache first shell-<version> Served from cache
hashed assets /assets/* Cache first assets-<version> Served from cache or network error
cached media Same-origin /media/* range requests 206 from cache, else network media Cached files only
images Same-origin image destination Cache first, 200 entries images Placeholder SVG
api reads GET /api/* Network first, 3 s timeout api Cached JSON, then 503 JSON
web fonts font from fonts.gstatic.com Cache first, 30 entries fonts Fallback font
stylesheets Any other style request Stale-while-revalidate assets-<version> Cached copy
everything else - Falls through to the network - Browser default

Ways to extend it without changing its shape:

  • Add the POST outbox from An offline outbox for API writes as a route with method("POST").
  • Add a streamed article route that returns streamedArticle(ctx.event).
  • Declare the fall-through paths (/admin/*, third-party analytics) as static routes with source network in the install handler so they never wake the worker in Chrome 123+ and Safari 27 (see Static Routing API).
  • Replace the hand-written strategies with Workbox's when you need expiration by age, broadcast updates or background sync plugins (see Workbox Fundamentals).

Browser support

Support data as of September 2026. For live data, check MDN's FetchEvent compatibility table and caniuse.com's service worker entry.

Feature Chrome / Edge Firefox Safari (macOS and iOS)
fetch event, respondWith() ✅ 42 ✅ 44 ✅ 11.1
clientId ✅ 49 ✅ 45 ✅ 11.1
resultingClientId ✅ 72 ✅ 65 ✅ 16
handled ✅ 86 ✅ 84 ✅ 16
preloadResponse (navigation preload) ✅ 59 ✅ 99 ✅ 15.4
replacesClientId ❌ ❌ ❌ (as targetClientId: 11.1 to 15.x)
Network error for cors response to same-origin request ✅ 66 ✅ 59 ❌
Response.json() ✅ 105 ✅ 115 ✅ 17
Response.redirected ✅ 57 ✅ 49 ✅ 10.1
URLPattern ✅ 95 ✅ 142 ✅ 26
Request.body stream getter ✅ 105 ❌ ✅ 11.1
Streaming request bodies (duplex: "half") ✅ 105 ❌ ❌
Range preserved by fetch(event.request) ✅ 87 ⚠️ ✅
Response URL used as final URL of subresources ❌ ✅ 59 ❌
Static routing (InstallEvent.addRoutes()) ✅ 123 ❌ ✅ 27

⚠️ web.dev reported in October 2020 that Firefox dropped the Range header when a service worker passed a media request to fetch(); falling through (not calling respondWith()) avoids the issue in every engine.

Chromium's skipping of no-op fetch handlers (warnings from Chrome 112, skipping from Chrome 115) is an engine optimization rather than a web-exposed feature, so it is not listed. Edge versions before 79 used the EdgeHTML engine, which supported service workers from Edge 17; all current Edge versions match Chrome.

Common pitfalls

  1. An async listener that calls respondWith() after an await. Throws InvalidStateError, and the request falls through. Call respondWith() synchronously with a promise.
  2. respondWith(caches.match(request)) without a fallback. A cache miss resolves to undefined, which becomes a network error.
  3. fetch(event.request.url) instead of fetch(event.request). Loses mode, credentials, method, body, headers and redirect mode; turns no-cors images into failing CORS requests.
  4. Reading a request body and then forwarding the request. "Cannot construct a Request with a Request object that has already been used." Clone first.
  5. Cloning a response after returning it. clone() throws once the body is used or locked; clone synchronously before return.
  6. Serving redirected responses to navigations. Precache final URLs or copy responses with cleanResponse().
  7. Treating !response.ok as "offline" for navigations. Breaks every redirect (opaqueredirect has ok === false) and hides real 404 pages behind stale content.
  8. Caching opaque responses with cache-first. Errors get cached forever and each entry costs about 7 MiB of quota on average in Chrome.
  9. Awaiting clients.get(event.resultingClientId) inside respondWith(). Deadlocks the navigation; use waitUntil().
  10. A catch-all respondWith(fetch(event.request)). Adds latency and failure modes to requests you do not change; fall through instead.
  11. A no-op fetch listener for installability. Not needed for menu installs since Chrome 108 (Android) and 112 (desktop), and current Chromium's install promotion has no service worker check either; remove it.
  12. Adding fetch listeners asynchronously. Listeners must be registered during the initial script evaluation.
  13. Rebuilding media requests with new Request(request, init). Drops the Range header from no-cors requests.
  14. Calling event.preventDefault() by habit. Without respondWith() it produces a network error instead of falling through.
  15. Caching per-user API responses without a logout plan. Cache Storage is shared by every user of the browser profile on that origin; clear user-specific caches on logout (see Privacy & Storage Partitioning).
  16. Synthesized redirects that loop. Worker-generated redirects for subresources come back to the worker; guard against redirecting a URL to itself.

More cross-cutting mistakes are collected in Pitfalls & Anti-Patterns.

Debugging

Chrome and Edge DevTools. The Application panel's Service workers pane has three switches that matter for fetch handling: Offline (network emulation), Update on reload, and Bypass for network, which "bypasses the service worker and forces the browser to go to the network for requested resources". Its Network requests link opens the Network panel filtered with is:service-worker-intercepted. In the Network panel, the Timing tab of an intercepted request shows ServiceWorker Preparation (worker start-up) and Request to ServiceWorker (dispatch), which is where you see the cost described in The cost of a fetch handler; requests made by the worker itself are marked with a gear icon. chrome://serviceworker-internals lists every registration with start, stop and inspect controls. See Browser DevTools for a full tour.

Firefox. about:debugging#/runtime/this-firefox lists registered workers with Inspect (a dedicated console and debugger for the worker) and Unregister. Worker console output, including your route logs, appears in that inspector rather than in the page's console.

Safari. Open the worker's inspector from the Develop menu's Service Workers submenu (turn on the web developer features in Safari's settings first). For iOS and iPadOS, connect the device and use the same menu under the device's name.

Console messages to recognize:

Message (Chromium) Meaning
... resulted in a network error response: the promise was rejected. Your respondWith() promise rejected; add a fallback.
... an object that was not a Response was passed to respondWith(). Usually undefined from a cache miss.
... a redirected response was used for a request whose redirect mode is not "follow". A redirected cached response answered a navigation.
The event handler is already finished. respondWith() called asynchronously.
Fetch event handler is recognized as no-op. ... Remove the empty listener.
The service worker navigation preload request was cancelled before 'preloadResponse' settled. ... Preload enabled but not used; see Navigation Preload.

Instrument your own routes. During development, add an X-SW-Route header with withHeaders() so the Network panel shows which route produced each response, and read PerformanceResourceTiming.workerStart in the page to measure start-up in the field (see Measuring Performance).

Further reading

On this site

External references