Skip to content

API in Service Worker

FetchEvent and request routing

Published

A FetchEvent is dispatched in the service worker for every navigation and subresource request made by a controlled client; calling event.respondWith() replaces the network response with any Response, and not calling it lets the request proceed to the network unchanged. The interface is available in Chrome 40, Edge 17, Firefox 44, and Safari 11.1 (BCD api.FetchEvent), with preloadResponse and handled added later and covered on their own entries.

self.addEventListener('fetch', (event) => {
event.respondWith(responsePromise); // optional: take over the response
event.waitUntil(promise); // optional: keep the worker alive for side effects
});

respondWith() must be called synchronously inside the handler; after the handler returns, the browser has already decided whether to go to the network. waitUntil() can be called at any point while the event is still being handled, including from inside the respondWith() promise chain.

The event exposes the request and the client identifiers; the two methods control the response and the worker’s lifetime.

Member Type Description
request Request The intercepted request. url, method, headers, mode, destination, and credentials are the properties used for routing.
respondWith(r) void Takes Response or Promise<Response>. The promise’s settlement is the response the client receives.
waitUntil(p) void Extends the event’s lifetime until p settles, so a cache write started after respondWith() is not cut off when the worker is terminated.
clientId string Id of the client that made the request. Empty for a navigation, because the navigating client does not exist yet.
resultingClientId string Id of the client a navigation will create. Empty for subresource requests.
replacesClientId string Id of the client being replaced by a navigation (same-window navigations only).
handled Promise<void> Resolves when the response has been handed to the client, or rejects if the event ended with a network error (BCD api.FetchEvent.handled).
preloadResponse Promise<Response | undefined> The navigation-preload response, or undefined when preload did not run; see the NavigationPreloadManager entry.

request.destination is the most reliable routing key: 'document' for navigations, 'script', 'style', 'image', 'font', and '' for fetch() and XMLHttpRequest calls. request.mode === 'navigate' is equivalent to destination === 'document' for top-level pages but also matches iframes, whose destination is 'iframe'.

respondWith() throws synchronously; the response-side failures are delivered to the client as a network error, not as an exception in the worker.

  • InvalidStateError: respondWith() was called after the handler returned, or called a second time on the same event.
  • Network error to the client: the promise passed to respondWith() rejects, or resolves to something that is not a Response. Chromium logs The FetchEvent for "<url>" resulted in a network error response: an object that was not a Response was passed to respondWith(). in the worker console and the page sees TypeError: Failed to fetch (or an error page for navigations).
  • Network error to the client: the Response body has already been used, for example because it was passed to cache.put() without clone().
  • TypeError from new Request(event.request, init) when the request is a navigation (mode: 'navigate') and init is not empty; build a new Request from event.request.url instead when a navigation request needs modified headers.

The handlers below are written so that only the branch that matches calls respondWith(); every other request falls through to the network.

Images get a cache-first helper, API calls bypass the cache, and everything else is left to the browser.

self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.origin !== self.location.origin) return; // third-party: do not intercept
if (event.request.destination === 'image') {
event.respondWith(cacheFirst(event.request, 'images-v1'));
} else if (url.pathname.startsWith('/api/')) {
event.respondWith(fetch(event.request)); // explicit network-only
}
});
async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
}

Returning early for other origins matters even for a network-only route: once respondWith() is called, the worker is responsible for the full response, including CORS behaviour and opaque responses.

Serving the app shell for navigations with waitUntil()

Section titled “Serving the app shell for navigations with waitUntil()”

A single-page app answers every navigation from the cached shell and refreshes that shell in the background, keeping the worker alive for the refresh.

self.addEventListener('fetch', (event) => {
if (event.request.mode !== 'navigate') return;
event.respondWith(
caches.match('/index.html').then((cached) => cached ?? fetch(event.request))
);
event.waitUntil(
fetch('/index.html').then((fresh) =>
fresh.ok ? caches.open('shell-v1').then((c) => c.put('/index.html', fresh)) : undefined
).catch(() => {})
);
});

Without waitUntil() the browser may terminate the worker as soon as respondWith() settles, dropping the background refresh. The catch prevents an offline refresh from surfacing as an unhandled rejection.

Detecting service worker support and keeping fetches working without it

Section titled “Detecting service worker support and keeping fetches working without it”

Pages served without a controlling worker (first visit, insecure origins, a hard reload) must still load. Register conditionally and do not make page code depend on interception.

if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch((err) => {
console.warn('Service worker registration failed; running without offline support', err);
});
}
// Page code calls fetch('/api/items') the same way in both cases: the worker
// is an optimisation layer, so the request works when no worker is in control.

navigator.serviceWorker.controller is null until a worker controls the page, which is also the state after a hard reload (Shift+Reload) in Chrome and Firefox; code that reads it must handle null.

Specifications

SpecificationStatus
None.