How to serve stale-while-revalidate pages with Cache.match() and a service worker
Use Cache.match() for an immediate cached response, refresh the resource in the fetch event, and notify the client only after the new copy is stored so the UI stays responsive without serving stale data forever.
A service worker can make a page feel instant without freezing it in time. The useful pattern is not “serve from cache and hope for the best,” but “serve the last known good response immediately, then refresh it behind the scenes and tell the client only after the new version is safely stored.”
That distinction matters because the browser has three separate responsibilities here: reading from cache, fetching from the network, and telling the page that something changed. This article ties those parts together with the exact primitives MDN documents for service workers and `Cache.match()`, then uses the supplied runtime and tests to show where the pattern succeeds, where it falls back, and where it must refuse to overwrite newer content.
The sequence the reader needs
Stale-while-revalidate only works when the user sees one version immediately and the application quietly prepares another one behind the scenes. The important part is the order. First, the service worker checks the cache. If a response exists, the page can render that value right away. Then the worker starts a background fetch so the next navigation or refresh has a better copy. Finally, the client is notified only after the cache has been updated. That last step prevents a mismatch where the UI claims freshness before the stored response is actually fresh.
The supplied runtime models that contract with a small `handleFetch(url)` function. When `cacheStore.match(url)` finds a value, the function returns `{ source: 'cache', response: ... }` immediately and also exposes a background promise. That lets the caller keep rendering while the network request proceeds. If the cache is empty, the runtime takes the slower path: fetch the network response, store it, and notify clients. If both cache and network fail, the handler still returns an explicit offline-shaped response instead of leaving the caller with nothing.
This article stays narrow on purpose. It does not try to design a general caching library, a complete offline app shell, or an HTTP cache validation strategy. It explains the ownership boundary that makes this pattern safe: the current response is allowed to come from cache, but the future response must be written and verified before anyone treats it as the new truth.
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 };
}
SourcesMDN: Cache.match() (opens a new tab)MDN: Using Service Workers (opens a new tab)
What `Cache.match()` promises, and what it does not
`Cache.match()` is a lookup, not a policy engine. MDN describes it as returning the first matching cached `Response`, or `undefined` when there is no match. That means the cache branch must treat “nothing found” as a normal outcome and choose a different path. In the supplied implementation, that choice is visible in the first `if (cached)` branch and in the later network fallback. The article does not assume cache availability; it checks it.
The secure-context requirement matters too. A service worker and the Cache API live in browser security boundaries, not in ordinary page scripts. The article therefore treats the cache path as a browser capability that depends on HTTPS and worker support, not as an abstract JavaScript helper. That distinction is useful because developers often test the happy path on localhost and then discover the same code behaves differently when the deployment context changes.
`Cache.match()` also returns only the first matching response, which makes it good for a single canonical page entry but not a tool for synthesizing freshness from multiple stored candidates. In this article’s runtime, one URL maps to one current response. That is exactly what a stale-while-revalidate flow needs: a quick answer if it exists, and a separate mechanism for making the next answer better.
Why the handler returns before the refresh finishes
The main mistake in stale-while-revalidate implementations is waiting for the network before responding to the request that already has a cached answer. That turns a fast cached page into a delayed network page. The supplied `handleFetch()` avoids that by starting `backgroundUpdate(url)` and returning the cached snapshot immediately. The returned object even carries a `background` promise so the test can wait for the refresh without making the user wait for it.
That shape mirrors the service-worker lifecycle described by MDN. A worker can control fetches while a newer version is installing or waiting, and activation happens separately from request handling. The important lesson is that fetch handling and cache maintenance are not the same event. A request can be answered now while a maintenance task continues later. Keeping those actions separate is what preserves responsiveness.
The implementation is careful about cloning the data it returns. The cached value and the network value are both spread into fresh objects before being sent back. That is a small detail, but it prevents the caller from observing later cache mutation through the original object reference. In a real service worker, the equivalent concern is `Response.clone()`: once a response is consumed, it cannot simply be reused in two different places. The article’s example keeps that ownership rule visible even though the sample stores plain objects instead of actual `Response` instances.
Why notify after the write completes
A notification is only useful if it describes a state the cache has already reached. That is why `backgroundUpdate()` writes the fresher network response before it calls `notifyClients({ type: 'updated', ... })`. The client can safely react to that message by re-reading the cache or requesting the same URL again. If the order were reversed, the page could receive an “updated” signal while still being served the old value from cache, which creates a confusing race between what the worker says and what the worker stores.
MDN’s discussion of service-worker updates helps explain why that order matters. A new worker can install in the background, but the previous worker keeps controlling existing pages until activation. That means a site can temporarily have more than one version of its logic in flight. The example avoids turning that overlap into a user-visible contradiction by keeping the storage write and the client notification together in one background step.
The notification itself is deliberately generic. The article does not assume a particular client API such as `clients.matchAll()` or `postMessage()`, because the important behavior is not the transport. The important behavior is that the message happens after the cache write succeeds. That is the point where the client can trust that freshness has moved from “in progress” to “stored and available.”
How the race is prevented
The most subtle bug in stale-while-revalidate is accepting every late network response as if it were automatically better than what is already cached. The supplied implementation does not do that. `backgroundUpdate()` reads the existing cache entry after the network response arrives, then compares timestamps. Only when `networkResponse.ts >= existing.ts` does it write the new value. Otherwise it emits a `skipped-old` notification and leaves the newer cache entry alone.
That compare-and-set rule is the reason the article can explain a real failure instead of just telling readers to “refresh in the background.” Without the comparison, an older response that arrives late can overwrite a newer one that was already stored. That failure is easy to miss in a simple demo because the network usually returns in a neat order. In production, however, retries, jitter, or stalled requests can invert the order and make the cache worse instead of better.
The timestamp is intentionally synthetic in this example. The mechanism matters more than the source of freshness. In a real application, the freshness check might come from a version header, an ETag-derived decision, or application-owned metadata. The key decision remains the same: compare the candidate response against what is already present, and refuse to let an older payload replace a newer one. That is what keeps the cache useful as a fast answer instead of a random last-writer-wins store.
SourcesMDN: Cache.match() (opens a new tab)MDN: Using Service Workers (opens a new tab)
What the tests prove, and what remains open
The Node tests attached to the implementation validate three useful outcomes. First, cached content is returned immediately and the refresh happens later. Second, a cache miss falls back to the network and stores the result. Third, an older network response does not replace a newer cached one. Those three checks match the exact contract the article describes, so they are more useful than a broad “it works” assertion.
The tests also show one important boundary: the runtime is a controlled object model, not a live browser service worker. That means the article can prove ownership order, cache replacement rules, and fallback behavior, but it cannot claim to have exercised `event.respondWith()`, actual `Response` cloning, navigation preload, or browser lifecycle activation. Those belong to a real browser integration test, which is a different proof problem from the one this article solves.
This limit is not a weakness of the article; it is part of its honesty. The code is intentionally small enough to make the ordering visible. That makes it a good debugging article, because the reader can see exactly which branch returns now, which branch updates later, and which branch is refused when the refresh would make things worse. The article therefore answers the original question without pretending the sample covers every service-worker feature or every browser edge case.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createMemoryCache, createSwRuntime } from './main.mjs';
const response = (body, ts) => ({ body, ts });
test('returns cached content immediately and refreshes cache in the background', async () => {
const cache = createMemoryCache();
await cache.put('/page', response('stale', 10));
const notifications = [];
const sw = createSwRuntime({
cacheStore: cache,
fetchFn: async () => response('fresh', 20),
notifyClients: (message) => notifications.push(message),
});
const result = await sw.handleFetch('/page');
assert.equal(result.source, 'cache');
assert.equal(result.response.body, 'stale');
await result.background;
const updated = await cache.match('/page');
assert.equal(updated.body, 'fresh');
assert.deepEqual(notifications, [
{ url: '/page', type: 'updated', response: { body: 'fresh', ts: 20 } },
]);
});
test('uses the network on a cache miss and stores the response', async () => {
const cache = createMemoryCache();
const notifications = [];
const sw = createSwRuntime({
cacheStore: cache,
fetchFn: async (url) => response(`network:${url}`, 30),
notifyClients: (message) => notifications.push(message),
});
const result = await sw.handleFetch('/missing');
assert.equal(result.source, 'network');
assert.equal(result.response.body, 'network:/missing');
const stored = await cache.match('/missing');
assert.equal(stored.body, 'network:/missing');
assert.deepEqual(notifications, [
{ url: '/missing', type: 'updated', response: { body: 'network:/missing', ts: 30 } },
]);
});
test('keeps a newer cached response when the network returns an older one', async () => {
const cache = createMemoryCache();
await cache.put('/race', response('newer-cache', 100));
const notifications = [];
const sw = createSwRuntime({
cacheStore: cache,
fetchFn: async () => response('older-network', 50),
notifyClients: (message) => notifications.push(message),
});
const result = await sw.handleFetch('/race');
assert.equal(result.source, 'cache');
assert.equal(result.response.body, 'newer-cache');
await result.background;
const final = await cache.match('/race');
assert.equal(final.body, 'newer-cache');
assert.deepEqual(notifications, [
{ url: '/race', type: 'skipped-old', response: { body: 'older-network', ts: 50 } },
]);
});
SourcesMDN: Using Service Workers (opens a new tab)MDN: Cache.match() (opens a new tab)


