Memoize Functions With a TTL Cache

Cache expensive function results by a custom key with per-entry TTL, bounded size, eviction, hit statistics, and manual invalidation.

JavascriptResilience

Code

snippet
export function memoize(fn, options = {}) {
  if (typeof fn !== "function") {
    throw new TypeError("fn must be a function");
  }
  const {
    ttlMs = 60_000,
    maxSize = 500,
    key = (...args) => JSON.stringify(args),
    now = Date.now,
  } = options;
  if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
    throw new RangeError("ttlMs must be a positive finite number");
  }
  if (!Number.isInteger(maxSize) || maxSize < 1) {
    throw new RangeError("maxSize must be a positive integer");
  }
  if (typeof key !== "function" || typeof now !== "function") {
    throw new TypeError("key and now must be functions");
  }

  const cache = new Map();
  const stats = { hits: 0, misses: 0, evictions: 0, expirations: 0 };

  const memoized = function memoized(...args) {
    const cacheKey = key(...args);
    const timestamp = now();
    const existing = cache.get(cacheKey);
    if (existing !== undefined) {
      if (existing.expiresAt > timestamp) {
        stats.hits += 1;
        cache.delete(cacheKey);
        cache.set(cacheKey, existing);
        return existing.value;
      }
      cache.delete(cacheKey);
      stats.expirations += 1;
    }

    stats.misses += 1;
    const value = fn.apply(this, args);
    if (cache.size >= maxSize) {
      const oldestKey = cache.keys().next().value;
      if (oldestKey !== undefined) {
        cache.delete(oldestKey);
        stats.evictions += 1;
      }
    }
    cache.set(cacheKey, { value, expiresAt: timestamp + ttlMs });
    return value;
  };

  memoized.clear = () => cache.clear();
  memoized.stats = () => ({ ...stats, size: cache.size });
  return memoized;
}

let calls = 0;
const slowSquare = memoize(
  (value) => {
    calls += 1;
    return value * value;
  },
  { ttlMs: 40 },
);

console.log(slowSquare(4), slowSquare(4), calls);
console.log(slowSquare(5), calls);
console.log(slowSquare.stats());
await new Promise((resolve) => setTimeout(resolve, 50));
console.log(slowSquare(4), calls);
slowSquare.clear();
console.log(slowSquare.stats());