Web Platform · Progressive Web

Fetch Event Interception and Response Construction

How a service worker receives requests, what respondWith accepts, why a rejected promise produces an error page instead of a network fetch, and which requests never reach the handler.

A controlled page issues a fetch() or an <img> load and the browser consults the service worker registration before the network. If the registration has an active worker whose script added a fetch listener at top level, the request is turned into a FetchEvent and dispatched. The handler has one chance to answer it. Everything the offline behaviour of a site depends on sits inside that chance.

The dispatch decision

The browser dispatches a fetch event for a request when either the request comes from a client controlled by the registration, or it is a navigation whose URL falls inside the registration’s scope. In addition, the request must not be the service worker script itself, must not be a chrome-extension: or devtools-originated request, and must not have a destination the browser handles internally. Requests made by the worker itself through fetch() do not re-enter its own handler; a worker that wants to consult its own cache calls caches.match() directly, and a worker that wants the network calls fetch() without interception. This asymmetry is what prevents an infinite recursion when a handler responds by fetching the same URL.

Navigation requests and subresource requests both arrive here, but they are distinguishable. event.request.mode is 'navigate' for a document load, 'cors' or 'no-cors' for most subresources, and 'same-origin' for some same-origin fetches. The destination property further narrows it: 'document', 'script', 'style', 'image', 'font', 'audio', 'video', and the empty string for fetch() calls that are not tied to a particular consumer. A handler that treats every request identically usually ends up caching HTML under URLs that the router also assigns to API responses, a recurring offline bug.

Delivery does not mean the handler can answer everything. Requests with a Range header arrive, but partial responses need explicit handling; a handler that answers with a full 200 body for a video element’s range request can break seeking or force the browser to refetch. Requests to other origins do reach the handler when a controlled document initiates them, and the response returned is then subject to the page’s CORS rules: an opaque response can be used as an image or media source but cannot be read.

respondWith and the promise contract

FetchEvent extends ExtendableEvent. respondWith(promise) tells the browser that the handler will supply the response; waitUntil(promise) extends the event’s lifetime without supplying a response. Both must be called synchronously inside the handler, before it returns. A call made after an await throws InvalidStateError; if no respondWith call succeeded, the request goes to the network as if no handler existed.

self.addEventListener('fetch', (event) => {
  // Synchronous decision: only GET navigations are answered from the shell cache.
  if (event.request.mode === 'navigate' && event.request.method === 'GET') {
    event.respondWith(
      caches.match('/shell.html').then((cached) => cached || fetch(event.request))
    );
    return;
  }
  // Everything else falls through to the browser's default network handling.
});

The promise passed to respondWith has three possible outcomes, and they are not interchangeable:

  • It resolves to a Response. That response is delivered to the page, and if it is a redirect, the redirect is followed under the rules for the request’s redirect mode.
  • It rejects. The request fails with a network error. The browser does not fall back to the network, and it does not silently ignore the handler. For a navigation this surfaces as the browser’s error page; for a subresource it surfaces as a failed load.
  • It resolves to something that is not a Response, or the handler calls respondWith a second time. A non-Response value makes the request fail as if the promise had rejected; a second call throws InvalidStateError and leaves the first response in place.

The rejection case is the one that catches people. event.respondWith(fetch(event.request)) rejects whenever the network is unavailable, producing a hard failure instead of a cache hit, because the handler took responsibility and then broke it. A cache-first handler needs a .catch() that returns a synthesized response, not a bare fallthrough.

Constructing responses

A Response can be built from nothing, from a string, from a Blob, or from a ReadableStream, and it can carry a status code and headers of the handler’s choosing. Constructing one bypasses the network entirely, which makes it the tool for offline fallbacks and for rewriting responses. Which fallback to serve, and with which status code, is covered in Offline Navigation, App Shell, and Fallback Routing.

const offline = new Response('<h1>Offline</h1>', {
  status: 503,
  statusText: 'Service Unavailable',
  headers: { 'Content-Type': 'text/html; charset=utf-8' }
});

Two limits apply to constructed responses. The Response constructor accepts only status codes from 200 to 599, so a constructed response cannot carry status 0 or 101 at all; of the constructible codes, 204, 205, and 304 must have a null body because HTTP forbids one. And a constructed response has no URL of its own. That does not change the delivered document’s address: for a navigation, the document’s URL is the request URL, so relative URLs inside a shell served at /reports/2025 resolve against /reports/, regardless of the fact that the body came from a cache entry stored at /shell.html. Shells that must work at arbitrary depths either use absolute paths or set a <base href="/">.

Responses that come from fetch() may be streamed through untouched; the browser forwards headers and body as they arrive. Responses that are read in the handler — to inspect them, to rewrite a header, to inject markup — must be consumed, and a body can only be consumed once. response.clone() produces a second readable copy backed by the same source, but the tee buffers for whichever copy is read more slowly, so cloning a large streaming response can cost memory proportional to the body size.

Request fidelity when re-fetching

Answering a request by calling fetch(event.request) is the safest default because the Request object carries the original’s credentials mode, headers, referrer, and redirect mode. Reconstructing the request from its URL alone changes behaviour in ways that are easy to overlook: credentials: 'omit' on a page-initiated request is silently upgraded to the default 'same-origin', adding cookies the page did not intend to send, and a request with redirect: 'manual' must keep that mode or the handler will follow a redirect the calling code was prepared to inspect. Forbidden headers such as Cookie and Host are stripped from the Request visible to the worker, so a handler cannot read or forward them by name; the browser attaches credentials when it performs the fetch.

Subresource integrity is checked against the bytes the page receives, not against the bytes the network produced. A cached or rewritten response that differs by a single byte from the file named in the integrity attribute fails validation and the resource is discarded, which is why cache entries for hashed assets should be stored from a response the browser already accepted rather than assembled by concatenation.

Redirects deserve their own caution. Response.redirect() accepts only the 301, 302, 303, 307, and 308 status codes and requires a URL that parses as absolute. Returning a redirect to a navigation makes the browser change the address bar; returning one to a subresource produces a fetch that follows it, subject to the request’s redirect mode. A redirect: 'manual' fetch surfaces as an opaque redirect response with status 0, an empty header list, and a null body, so a handler cannot rewrite the Location of a redirect it did not itself create.

Interception has a cost even when the handler is trivial. Every controlled request crosses into the worker’s thread, waits for the handler’s promise, and returns. For a worker that was terminated, that means starting the worker before the request can proceed. The per-request overhead is small in absolute terms but applies to every image, font, and script on the page, so handlers are usually written to return early for destinations they do not manage rather than routing everything through a common path.

Three categories deserve to bypass interception entirely. Requests whose URL would be rewritten to a different origin’s resource should be left to the browser’s own HTTP cache, which already implements conditional requests, Vary handling, and range requests. Requests for analytics and other fire-and-forget beacons are better served by not being answered at all: an unhandled event lets the browser send them normally, while a handler that intercepts and re-fetches them rewrites their timing and can drop them when the worker is not running. Media elements issuing Range requests are the third: answering them with a full body may play but can defeat seeking, so a handler either implements byte-range slicing against a cached ArrayBuffer or leaves those requests alone. The service worker script itself is a separate case — it is never intercepted, so an offline strategy cannot rely on caching sw.js in order to keep the worker installable.

The boundary between interception and caching is examined in Cache Storage Keys, Vary, and Cache Versioning: the fetch event decides whether the worker speaks for a request, and the cache decides what it can say. Keeping those two concerns in separate functions makes a handler auditable, because a single respondWith chain that mixes strategy selection, cache lookup, network fetch, and fallback logic is difficult to reason about when one of the four fails.

References

  1. Fetch Standard — WHATWG
  2. Service Workers Nightly — W3C Web Applications Working Group
  3. FetchEvent — MDN Web Docs