Prerequisites
- Familiarity with service workers and the Cache API concept. You should be comfortable reasoning about cache lookups and writes and have a freshness signal available (timestamp, ETag, or version).
Implementation decision
Return the cached response immediately using Cache.match() (or cacheStore.match). Start a background fetch that compares the incoming network payload with the currently stored entry. Only write the network response when it is at least as fresh as the cached value. Notify the client after the write succeeds. If the cache is empty, fetch, store, notify, and return the network response.
Expected result
- Instant UI: users see cached content immediately when available.
- Background refresh: the worker refreshes the resource for future navigations without blocking the initial render.
- Trustworthy notifications: the client’s “updated” signal follows a successful cache write, so re-reading the cache gives the new value.
- Race avoidance: re-reading the cache before writing prevents a late, older network response from clobbering a newer cached copy.
One limitation
The pattern assumes a single canonical response per URL and a freshness comparison mechanism you implement. This mini-runtime demonstrates ordering and replacement guarantees but does not exercise browser-specific behaviors such as Response.clone() or event.respondWith(); those require real browser integration testing.
(Verified implementation will be inserted by the server.)
Working example
export function createMemoryCache() {
const store = new Map();
return {
async match(url) {
const value = store.get(url);
return value ? { ...value } : undefined;
},
async put(url, response) {
store.set(url, { ...response });
},
async delete(url) {
return store.delete(url);
},
async keys() {
return [...store.keys()];
},
};
}
export function createSwRuntime({ cacheStore, fetchFn, notifyClients }) {
if (!cacheStore || typeof cacheStore.match !== 'function' || typeof cacheStore.put !== 'function') {
throw new TypeError('cacheStore must provide async match(url) and put(url, response)');
}
if (typeof fetchFn !== 'function') {
throw new TypeError('fetchFn must be a function');
}
if (typeof notifyClients !== 'function') {
throw new TypeError('notifyClients must be a function');
}
async function backgroundUpdate(url) {
try {
const networkResponse = await fetchFn(url);
const existing = await cacheStore.match(url);
if (!existing || networkResponse.ts >= existing.ts) {
await cacheStore.put(url, networkResponse);
notifyClients({ url, type: 'updated', response: { ...networkResponse } });
} else {
notifyClients({ url, type: 'skipped-old', response: { ...networkResponse } });
}
} catch (error) {
notifyClients({ url, type: 'update-failed', error: String(error) });
}
}
async function handleFetch(url, { offlineFallback } = {}) {
const cached = await cacheStore.match(url);
if (cached) {
const background = backgroundUpdate(url);
return {
source: 'cache',
response: { ...cached },
background,
};
}
try {
const networkResponse = await fetchFn(url);
await cacheStore.put(url, networkResponse);
notifyClients({ url, type: 'updated', response: { ...networkResponse } });
return {
source: 'network',
response: { ...networkResponse },
};
} catch {
if (offlineFallback) {
const fallback = await cacheStore.match(offlineFallback);
if (fallback) {
return {
source: 'fallback',
response: { ...fallback },
};
}
}
return {
source: 'error',
response: { body: 'offline', ts: Date.now(), offline: true },
};
}
}
return { handleFetch };
}
Read the full article: https://chriseugenerodriguez.com/blog/service-worker-stale-while-revalidate-cache-match