Skip to content

The Fetch Event

Once a service worker is activated and controls a page, every network request the page makes — HTML, CSS, JS, images, API calls — passes through the SW’s fetch event. This is the heart of offline-first PWAs.

Inside the fetch listener, call event.respondWith() with a Promise that resolves to a Response. If you do not call event.respondWith(), the request falls through to the network normally (passthrough behaviour).

self.addEventListener('fetch', (event) => {
// Simplest possible passthrough — behaves as if no SW is installed
event.respondWith(fetch(event.request));
});

Try the network; if it fails (offline or server error), fall back to the cache.

self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then((response) => {
// Clone the response: a Response body can only be consumed once
const clone = response.clone();
caches.open('v1').then((cache) => cache.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request))
);
});

Serve from cache immediately; if not cached, go to the network and cache the result.

self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
return fetch(event.request).then((response) => {
const clone = response.clone();
caches.open('v1').then((cache) => cache.put(event.request, clone));
return response;
});
})
);
});

Return the cached version instantly, then update the cache in the background.

self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open('v1').then((cache) =>
cache.match(event.request).then((cached) => {
const networkFetch = fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
});
return cached || networkFetch;
})
)
);
});
sequenceDiagram
  participant P as Page
  participant SW as Service Worker
  participant C as Cache Storage
  participant N as Network

  P->>SW: fetch(request)
  SW->>C: caches.match(request)
  alt cache hit
    C-->>SW: cached Response
    SW-->>P: Response (instant)
  else cache miss
    SW->>N: fetch(request)
    N-->>SW: fresh Response
    SW->>C: cache.put(request, response.clone())
    SW-->>P: Response
  end
Cache-first fetch flow through the service worker

Always clone before caching. A Response body is a readable stream that can only be consumed once. If you pass the same Response to both the caller and cache.put(), one of them will receive an empty body. Use response.clone() before storing.

Do not intercept cross-origin opaque responses carelessly. Requests to third-party origins without CORS return an opaque response (type === 'opaque') with status === 0. Caching opaque responses inflates cache storage unpredictably. Apply fetch-event logic only to same-origin requests unless you know what you are doing.

self.addEventListener('fetch', (event) => {
// Only handle same-origin requests
if (!event.request.url.startsWith(self.location.origin)) return;
event.respondWith(
caches.match(event.request).then((cached) => cached || fetch(event.request))
);
});
OptionBenefitCost
Intercept every fetch event and respondWith() yourselfFine-grained control over the caching strategy for every requestEvery request must be handled correctly, or a fetch can hang or error
Let requests fall through to the network normally (no interception)Simpler code, no risk of caching-logic bugsNo offline benefit or cache speedup at all
  • Not calling event.respondWith() on some code path, so the browser falls back to the network unintentionally and silently, with no error.
  • Caching the response of a POST/PUT request that should never be cached, breaking the mutation when it is replayed from cache.
  • Not checking request.mode or request.method before intercepting, so the fetch handler ends up touching requests that should just pass through — a cross-origin analytics call, for example.

Mail and document apps that need to stay usable offline typically split their fetch strategy by request type — static assets get a cache-first strategy, while API/data calls that were already fetched get served from cache as a fallback and synced once the connection returns. This is the same pattern the strategies above implement; which app you have seen do this specifically is less important than applying the split correctly in your own service worker.

What happens if the fetch handler calls event.respondWith() with a Promise that rejects?
Why must you clone a Response before passing it to cache.put()?
Which fetch strategy returns a cached response immediately and updates the cache in the background?
What is an opaque response?