A stale-while-revalidate cache returns a cached value immediately — even after it has gone stale — while kicking off a background refresh so the next read gets fresh data. It is the caching strategy behind the HTTP stale-while-revalidate directive and data libraries like SWR and React Query: keep the UI fast by never blocking a read on the network, and let the data catch up a moment later.
Implement staleWhileRevalidateCache(fetcher, { maxAge }). It returns a get(key) function. A cached value counts as fresh for maxAge milliseconds and stale after that. A read resolves in one of three ways: a cold miss awaits fetcher(key) and caches it; a fresh hit returns the cached value with no fetch; a stale hit returns the old value right away and starts a background fetcher(key) whose result it does not await. If a background refresh for a key is already running, a second stale read must not start another.
type SwrGet<K, V> = {
(key: K): Promise<V>; // get(key) — resolves fresh or stale value
invalidate(key: K): void; // drop one cached entry
clear(): void; // drop every cached entry
};
function staleWhileRevalidateCache<K, V>(
fetcher: (key: K) => Promise<V>,
options: { maxAge: number }, // ms a cached value stays "fresh"
): SwrGet<K, V>;
let calls = 0;
const get = staleWhileRevalidateCache(
async (key) => { calls++; return db.read(key); },
{ maxAge: 1000 },
);
await get('user:7'); // MISS -> awaits fetcher, caches it. calls === 1
await get('user:7'); // FRESH -> cached value, no fetch. calls === 1
// ...1000ms pass, the entry is now stale...
await get('user:7'); // STALE -> resolves the OLD value now,
// refreshes in the background. calls === 2
const get = staleWhileRevalidateCache(fetchProfile, { maxAge: 1000 });
await get('me'); // cache it
// ...maxAge passes, the entry is now stale...
const [a, b, c] = await Promise.all([get('me'), get('me'), get('me')]);
// a, b, and c are all the same stale value, returned immediately.
// Only ONE background fetchProfile('me') runs for the whole burst.
maxAge, return the old value immediately and start a background fetch you do not await.Date.now() - ts against maxAge, where ts is when the value was stored.get.invalidate(key) drops one entry and get.clear() drops all, forcing the next read to fetch.You'll build a cache that answers reads instantly — even with slightly old data — and quietly refreshes that data in the background so it never drifts far from the source.
A dashboard reads user:7 on nearly every render. A plain cache with an expiry makes every read after expiry wait for the network — a visible stall, right when the user is looking. You would rather hand back the last value you have the instant it is asked for, and go fetch the update behind the scenes. The screen stays fast, and the data catches up a moment later. That trade — serve now, refresh after — is stale-while-revalidate.
Think of the morning newspaper on your doorstep. When you pick it up you read today's edition right away, even though a newer one is already being printed; the fresh copy shows up for tomorrow. You never stand at the door waiting for the presses. Each key owns its own paper and its own delivery-in-progress: a read hands you whatever is on the step now, and if that paper is old it also nudges a new delivery into motion.
The obvious version keeps one Map of { value, ts } and, whenever the value is missing or past maxAge, fetches a new one and returns it:
function staleWhileRevalidateCache(fetcher, { maxAge }) {
const cache = new Map(); // key -> { value, ts }
return async function get(key) {
const entry = cache.get(key);
if (entry && Date.now() - entry.ts < maxAge) {
return entry.value; // fresh: serve from cache
}
// stale OR missing: fetch fresh data and wait for it
const value = await fetcher(key);
cache.set(key, { value, ts: Date.now() });
return value;
};
}
On a stale hit this does the one thing SWR must never do: it awaits the fetch before returning. The caller waits for the whole network round-trip even though a perfectly usable — if slightly old — value is already in hand. That is a plain time-to-live cache, not stale-while-revalidate; the instant response, the entire point, is gone. It also starts a fresh fetch for every stale read, so a burst of reads on an expired key hammers the server with duplicate requests.
The fix is two changes. On a stale hit, serve the cached value right away and start the refresh without awaiting it. And keep a second map of in-flight refreshes, so a key that is already refreshing does not start a second fetch.
function staleWhileRevalidateCache(fetcher, { maxAge } = {}) {
const cache = new Map(); // key -> { value, ts }
const inflight = new Map(); // key -> Promise, a fetch in progress
// Fetch fresh data for `key`. If a fetch is already running for this key,
// reuse it instead of starting another (this is the dedupe). Store the
// value on success; on failure cache nothing, so the next read retries.
function revalidate(key) {
if (inflight.has(key)) return inflight.get(key);
const p = Promise.resolve(fetcher(key)).then(
(value) => {
cache.set(key, { value, ts: Date.now() });
inflight.delete(key);
return value;
},
(err) => {
inflight.delete(key);
throw err;
},
);
inflight.set(key, p); // register before returning so a same-tick call dedupes
return p;
}
function get(key) {
const entry = cache.get(key);
// MISS: nothing cached yet. Await a fresh fetch.
if (!entry) return revalidate(key);
// FRESH: still inside the maxAge window. Serve the cached value, no fetch.
if (Date.now() - entry.ts < maxAge) {
return Promise.resolve(entry.value);
}
// STALE: past maxAge. Serve the OLD value immediately, and refresh in the
// background. We do NOT await the refresh; the .catch keeps a failed one
// from becoming an unhandled rejection (the stale value simply stays).
revalidate(key).catch(() => {});
return Promise.resolve(entry.value);
}
get.invalidate = (key) => { cache.delete(key); };
get.clear = () => { cache.clear(); };
return get;
}
module.exports = { staleWhileRevalidateCache };
The shift from the naive version lives in the stale branch. Instead of return await fetcher(key), you call revalidate(key) and throw the returned promise away, then immediately return the cached value. revalidate is where deduping happens: it checks inflight first and reuses a running fetch, so ten stale reads in the same tick share one request. It writes to cache only inside the success handler, so a rejected refresh leaves the old value untouched — which is why the stale .catch(() => {}) is safe to ignore. The miss path reuses the very same revalidate, so cold reads dedupe too.
Say maxAge = 1000 and fetcher returns 'v1', then 'v2', then 'v3' on successive calls. Watch key 'a':
get('a'). cache has no entry, so this is a MISS. You call revalidate('a'), which runs fetcher('a'), stores the promise in inflight, and returns it. When it resolves you store { value: 'v1', ts: 0 } and clear inflight. The caller gets 'v1'.get('a'). cache has { value: 'v1', ts: 0 } and 200 - 0 = 200, which is < 1000, so it is a FRESH hit. You return Promise.resolve('v1') — no fetch, no network.1500 - 0 = 1500, which is >= 1000, so both are STALE. The first calls revalidate('a'): inflight is empty, so it runs fetcher('a') (now 'v2') and registers the promise. The second calls revalidate('a') too, but inflight already holds 'a', so it reuses that one promise — fetcher does not run again. Both reads return the old 'v1' immediately.'v2'. The success handler writes { value: 'v2', ts: ~1500 } and clears inflight.get('a'). 1600 - 1500 = 100, which is < 1000, so it is a FRESH hit and returns 'v2'. The background refresh has caught the cache up, and no caller ever waited on it.return await fetcher(key) reintroduces the exact stall SWR removes; every stale read blocks on the network. Fix: return the cached value first, then start the refresh without awaiting it.inflight map, a burst of stale reads each starts its own fetch, so ten reads fire ten identical requests. Fix: reuse a running promise per key and delete it when it settles.revalidate(key) that rejects with no handler logs an unhandled promise rejection. Fix: attach .catch(() => {}) on the stale path; a failed refresh keeps the stale value and the next read retries.ts at call time — recording the timestamp when the fetch starts rather than when it resolves lets a slow fetch eat into the fresh window. Fix: set ts to Date.now() inside the success handler, after the value arrives.Map compares object keys by reference, so get({ id: 7 }) twice makes two entries that never hit. Fix: key by a primitive (a string id, or a JSON.stringify of the arguments).maxStale: serve stale only up to a limit, and past that block on the fetch like a normal cache so callers never see ancient data.AbortSignal into fetcher so a background revalidation that is no longer needed (the entry was invalidated) can be aborted instead of running to completion.Map grows without bound; add least-recently-used eviction or a periodic sweep that drops entries no one has read in a while.Promise.resolve().then(() => fetcher(key)) so a fetcher that throws synchronously becomes a rejected promise instead of throwing out of get.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A stale-while-revalidate cache returns a cached value immediately — even after it has gone stale — while kicking off a background refresh so the next read gets fresh data. It is the caching strategy behind the HTTP stale-while-revalidate directive and data libraries like SWR and React Query: keep the UI fast by never blocking a read on the network, and let the data catch up a moment later.
Implement staleWhileRevalidateCache(fetcher, { maxAge }). It returns a get(key) function. A cached value counts as fresh for maxAge milliseconds and stale after that. A read resolves in one of three ways: a cold miss awaits fetcher(key) and caches it; a fresh hit returns the cached value with no fetch; a stale hit returns the old value right away and starts a background fetcher(key) whose result it does not await. If a background refresh for a key is already running, a second stale read must not start another.
type SwrGet<K, V> = {
(key: K): Promise<V>; // get(key) — resolves fresh or stale value
invalidate(key: K): void; // drop one cached entry
clear(): void; // drop every cached entry
};
function staleWhileRevalidateCache<K, V>(
fetcher: (key: K) => Promise<V>,
options: { maxAge: number }, // ms a cached value stays "fresh"
): SwrGet<K, V>;
let calls = 0;
const get = staleWhileRevalidateCache(
async (key) => { calls++; return db.read(key); },
{ maxAge: 1000 },
);
await get('user:7'); // MISS -> awaits fetcher, caches it. calls === 1
await get('user:7'); // FRESH -> cached value, no fetch. calls === 1
// ...1000ms pass, the entry is now stale...
await get('user:7'); // STALE -> resolves the OLD value now,
// refreshes in the background. calls === 2
const get = staleWhileRevalidateCache(fetchProfile, { maxAge: 1000 });
await get('me'); // cache it
// ...maxAge passes, the entry is now stale...
const [a, b, c] = await Promise.all([get('me'), get('me'), get('me')]);
// a, b, and c are all the same stale value, returned immediately.
// Only ONE background fetchProfile('me') runs for the whole burst.
maxAge, return the old value immediately and start a background fetch you do not await.Date.now() - ts against maxAge, where ts is when the value was stored.get.invalidate(key) drops one entry and get.clear() drops all, forcing the next read to fetch.You'll build a cache that answers reads instantly — even with slightly old data — and quietly refreshes that data in the background so it never drifts far from the source.
A dashboard reads user:7 on nearly every render. A plain cache with an expiry makes every read after expiry wait for the network — a visible stall, right when the user is looking. You would rather hand back the last value you have the instant it is asked for, and go fetch the update behind the scenes. The screen stays fast, and the data catches up a moment later. That trade — serve now, refresh after — is stale-while-revalidate.
Think of the morning newspaper on your doorstep. When you pick it up you read today's edition right away, even though a newer one is already being printed; the fresh copy shows up for tomorrow. You never stand at the door waiting for the presses. Each key owns its own paper and its own delivery-in-progress: a read hands you whatever is on the step now, and if that paper is old it also nudges a new delivery into motion.
The obvious version keeps one Map of { value, ts } and, whenever the value is missing or past maxAge, fetches a new one and returns it:
function staleWhileRevalidateCache(fetcher, { maxAge }) {
const cache = new Map(); // key -> { value, ts }
return async function get(key) {
const entry = cache.get(key);
if (entry && Date.now() - entry.ts < maxAge) {
return entry.value; // fresh: serve from cache
}
// stale OR missing: fetch fresh data and wait for it
const value = await fetcher(key);
cache.set(key, { value, ts: Date.now() });
return value;
};
}
On a stale hit this does the one thing SWR must never do: it awaits the fetch before returning. The caller waits for the whole network round-trip even though a perfectly usable — if slightly old — value is already in hand. That is a plain time-to-live cache, not stale-while-revalidate; the instant response, the entire point, is gone. It also starts a fresh fetch for every stale read, so a burst of reads on an expired key hammers the server with duplicate requests.
The fix is two changes. On a stale hit, serve the cached value right away and start the refresh without awaiting it. And keep a second map of in-flight refreshes, so a key that is already refreshing does not start a second fetch.
function staleWhileRevalidateCache(fetcher, { maxAge } = {}) {
const cache = new Map(); // key -> { value, ts }
const inflight = new Map(); // key -> Promise, a fetch in progress
// Fetch fresh data for `key`. If a fetch is already running for this key,
// reuse it instead of starting another (this is the dedupe). Store the
// value on success; on failure cache nothing, so the next read retries.
function revalidate(key) {
if (inflight.has(key)) return inflight.get(key);
const p = Promise.resolve(fetcher(key)).then(
(value) => {
cache.set(key, { value, ts: Date.now() });
inflight.delete(key);
return value;
},
(err) => {
inflight.delete(key);
throw err;
},
);
inflight.set(key, p); // register before returning so a same-tick call dedupes
return p;
}
function get(key) {
const entry = cache.get(key);
// MISS: nothing cached yet. Await a fresh fetch.
if (!entry) return revalidate(key);
// FRESH: still inside the maxAge window. Serve the cached value, no fetch.
if (Date.now() - entry.ts < maxAge) {
return Promise.resolve(entry.value);
}
// STALE: past maxAge. Serve the OLD value immediately, and refresh in the
// background. We do NOT await the refresh; the .catch keeps a failed one
// from becoming an unhandled rejection (the stale value simply stays).
revalidate(key).catch(() => {});
return Promise.resolve(entry.value);
}
get.invalidate = (key) => { cache.delete(key); };
get.clear = () => { cache.clear(); };
return get;
}
module.exports = { staleWhileRevalidateCache };
The shift from the naive version lives in the stale branch. Instead of return await fetcher(key), you call revalidate(key) and throw the returned promise away, then immediately return the cached value. revalidate is where deduping happens: it checks inflight first and reuses a running fetch, so ten stale reads in the same tick share one request. It writes to cache only inside the success handler, so a rejected refresh leaves the old value untouched — which is why the stale .catch(() => {}) is safe to ignore. The miss path reuses the very same revalidate, so cold reads dedupe too.
Say maxAge = 1000 and fetcher returns 'v1', then 'v2', then 'v3' on successive calls. Watch key 'a':
get('a'). cache has no entry, so this is a MISS. You call revalidate('a'), which runs fetcher('a'), stores the promise in inflight, and returns it. When it resolves you store { value: 'v1', ts: 0 } and clear inflight. The caller gets 'v1'.get('a'). cache has { value: 'v1', ts: 0 } and 200 - 0 = 200, which is < 1000, so it is a FRESH hit. You return Promise.resolve('v1') — no fetch, no network.1500 - 0 = 1500, which is >= 1000, so both are STALE. The first calls revalidate('a'): inflight is empty, so it runs fetcher('a') (now 'v2') and registers the promise. The second calls revalidate('a') too, but inflight already holds 'a', so it reuses that one promise — fetcher does not run again. Both reads return the old 'v1' immediately.'v2'. The success handler writes { value: 'v2', ts: ~1500 } and clears inflight.get('a'). 1600 - 1500 = 100, which is < 1000, so it is a FRESH hit and returns 'v2'. The background refresh has caught the cache up, and no caller ever waited on it.return await fetcher(key) reintroduces the exact stall SWR removes; every stale read blocks on the network. Fix: return the cached value first, then start the refresh without awaiting it.inflight map, a burst of stale reads each starts its own fetch, so ten reads fire ten identical requests. Fix: reuse a running promise per key and delete it when it settles.revalidate(key) that rejects with no handler logs an unhandled promise rejection. Fix: attach .catch(() => {}) on the stale path; a failed refresh keeps the stale value and the next read retries.ts at call time — recording the timestamp when the fetch starts rather than when it resolves lets a slow fetch eat into the fresh window. Fix: set ts to Date.now() inside the success handler, after the value arrives.Map compares object keys by reference, so get({ id: 7 }) twice makes two entries that never hit. Fix: key by a primitive (a string id, or a JSON.stringify of the arguments).maxStale: serve stale only up to a limit, and past that block on the fetch like a normal cache so callers never see ancient data.AbortSignal into fetcher so a background revalidation that is no longer needed (the entry was invalidated) can be aborted instead of running to completion.Map grows without bound; add least-recently-used eviction or a periodic sweep that drops entries no one has read in a while.Promise.resolve().then(() => fetcher(key)) so a fetcher that throws synchronously becomes a rejected promise instead of throwing out of get.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.