Skip to content

Concept in Service Worker

Caching strategies for service workers

Published

A caching strategy is the rule a service worker’s fetch handler applies to decide between the Cache Storage copy and the network, and in which order. Five named strategies cover the space; every one of them is a short respondWith() body, and Workbox ships each as a class in workbox-strategies. The underlying primitives (FetchEvent, caches.match(), cache.put()) are Baseline widely available (Chrome 40, Firefox 44, Safari 11.1; BCD api.FetchEvent), so the choice is about trade-offs, not support.

Each strategy answers the same three questions differently: where the first lookup goes, what happens when that lookup fails, and whether the cache is updated afterwards.

Strategy First lookup On miss or failure Cache updated Cost the reader pays
Cache First caches.match() fetch(), then cache.put() Only on a miss A stale entry is served until its cache name changes or the entry is deleted
Network First fetch() caches.match() On every success One full network wait per request, plus the browser’s offline detection time before the fallback
Stale-While-Revalidate caches.match() fetch() On every request, in the background Exactly one response is stale on every request after the first
Network Only fetch() Fails No No offline behaviour at all
Cache Only caches.match() Fails No Anything not precached is a hard failure

Two mechanics apply to every strategy that writes to the cache. A Response body is a stream that can be read once, so the handler must clone() it before passing one copy to cache.put() and returning the other; forgetting the clone throws TypeError: Failed to execute 'put' on 'Cache': Response body is already used in Chromium. And fetch() only rejects on network failure: a 404 or 500 resolves normally, so a strategy that calls cache.put() without checking response.ok caches error pages.

Opaque responses (cross-origin requests made with mode: 'no-cors') have status 0 and an unreadable body. They can be stored, but Chromium pads each one to about 7 MB in quota accounting to prevent size-based cross-origin inference, so a Cache First strategy over third-party fonts or images consumes quota far faster than the bytes transferred suggest.

Network First has a hidden latency problem: when the device is on a captive portal or a dead Wi-Fi link, fetch() does not reject quickly; it waits for the TCP timeout. Add an explicit timeout (AbortSignal.timeout()) and treat it as a miss, which is what Workbox’s NetworkFirst networkTimeoutSeconds option does.

The hand-written handlers below each implement one row of the table; the last example shows the same routes in Workbox.

Stale-While-Revalidate with the clone done correctly

Section titled “Stale-While-Revalidate with the clone done correctly”

The cached copy is returned immediately when it exists; the network response refreshes the cache for the next request. The clone() happens before put() and the returned Response is the original.

self.addEventListener('fetch', (event) => {
if (event.request.destination !== 'image') return;
event.respondWith((async () => {
const cache = await caches.open('images-v1');
const cached = await cache.match(event.request);
const network = fetch(event.request).then((response) => {
if (response.ok) cache.put(event.request, response.clone());
return response;
});
event.waitUntil(network.catch(() => {})); // keep the worker alive for the refresh
return cached ?? network;
})());
});

Without event.waitUntil(network) the browser may terminate the worker after respondWith() settles, before the background refresh completes; the catch keeps a network failure from surfacing as an unhandled rejection.

Network First with a timeout and an offline fallback

Section titled “Network First with a timeout and an offline fallback”

The handler races the network against a three-second timer; a timeout or a failure falls back to the cache and, for navigations, to a precached /offline.html.

self.addEventListener('fetch', (event) => {
if (event.request.mode !== 'navigate') return;
event.respondWith((async () => {
const cache = await caches.open('pages-v1');
try {
const response = await fetch(event.request, { signal: AbortSignal.timeout(3000) });
if (response.ok) cache.put(event.request, response.clone());
return response;
} catch {
return (await cache.match(event.request)) ?? cache.match('/offline.html');
}
})());
});

AbortSignal.timeout() is available in Chrome 103, Firefox 100, and Safari 16 (BCD api.AbortSignal.timeout_static); in older workers, construct an AbortController and call abort() from setTimeout.

Detecting Cache Storage before relying on a strategy

Section titled “Detecting Cache Storage before relying on a strategy”

caches is only exposed in secure contexts (HTTPS and localhost; BCD api.caches), and a page served over plain HTTP has no navigator.serviceWorker.register() either. A page that depends on precached assets should check before registering the worker, and the worker should treat a missing caches as Network Only.

// In the page
if ('serviceWorker' in navigator && 'caches' in window) {
navigator.serviceWorker.register('/sw.js');
} else {
// No Cache Storage: the app runs online-only; do not advertise offline support.
}
// In the worker
self.addEventListener('fetch', (event) => {
if (!('caches' in self)) return; // fall through to the network
// …strategy code…
});

Returning from the handler without calling respondWith() lets the browser perform the request as if no worker existed, which is the correct degraded behaviour.

Each class in workbox-strategies implements one row of the table above and adds the clone, the ok check (via CacheableResponsePlugin), and the timeout as options.

import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
registerRoute(({ request }) => request.mode === 'navigate',
new NetworkFirst({ cacheName: 'pages', networkTimeoutSeconds: 3 }));
registerRoute(({ request }) => request.destination === 'image',
new StaleWhileRevalidate({ cacheName: 'images' }));
registerRoute(({ url }) => url.pathname.startsWith('/assets/'),
new CacheFirst({ cacheName: 'assets', plugins: [new CacheableResponsePlugin({ statuses: [200] })] }));

Without CacheableResponsePlugin, CacheFirst and StaleWhileRevalidate cache any response with status 200 or 0 (opaque) by default; NetworkFirst caches 200 only.

Specifications

SpecificationStatus
None.