Stale-While-Revalidate Cache With Background Refresh
Serve fresh entries instantly, return stale values while one background refresh runs, and surface refresh failures without breaking reads.
Code
export interface SwrOptions<K> {
ttlMs: number;
staleMs: number;
now?: () => number;
onError?: (error: unknown, key: K) => void;
}
interface CacheEntry<V> {
value: V;
freshUntil: number;
staleUntil: number;
}
export class SwrCache<K, V> {
private readonly entries = new Map<K, CacheEntry<V>>();
private readonly inflight = new Map<K, Promise<V>>();
private readonly ttlMs: number;
private readonly staleMs: number;
private readonly now: () => number;
private readonly onError?: (error: unknown, key: K) => void;
constructor(options: SwrOptions<K>) {
if (!Number.isFinite(options.ttlMs) || options.ttlMs <= 0) {
throw new RangeError("ttlMs must be a positive finite number");
}
if (!Number.isFinite(options.staleMs) || options.staleMs < 0) {
throw new RangeError("staleMs must be a non-negative finite number");
}
this.ttlMs = options.ttlMs;
this.staleMs = options.staleMs;
this.now = options.now ?? Date.now;
this.onError = options.onError;
}
get(key: K, loader: () => Promise<V>): Promise<V> {
if (typeof loader !== "function") {
return Promise.reject(new TypeError("loader must be a function"));
}
const now = this.now();
const entry = this.entries.get(key);
if (entry && now < entry.freshUntil) return Promise.resolve(entry.value);
if (entry && now < entry.staleUntil) {
void this.refresh(key, loader).catch((error) => {
try {
this.onError?.(error, key);
} catch {
// Observers must not break stale reads.
}
});
return Promise.resolve(entry.value);
}
return this.refresh(key, loader);
}
invalidate(key?: K): void {
if (key === undefined) this.entries.clear();
else this.entries.delete(key);
}
private refresh(key: K, loader: () => Promise<V>): Promise<V> {
const existing = this.inflight.get(key);
if (existing) return existing;
const promise = Promise.resolve()
.then(loader)
.then((value) => {
const now = this.now();
this.entries.set(key, {
value,
freshUntil: now + this.ttlMs,
staleUntil: now + this.ttlMs + this.staleMs,
});
return value;
})
.finally(() => {
this.inflight.delete(key);
});
this.inflight.set(key, promise);
return promise;
}
}Report a problem with this exampleOpens a pre-filled issue on GitHub.