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.

TypescriptData Fetching

Code

snippet
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;
  }
}