Memoize With TTL and Bounded Cache Size
The official memoize is unbounded and has no TTL, which works in tests and leaks memory in production. This is the version I ship: bounded LRU + per-entry expiry, in 40 lines.
By @jordandubois
November 27, 2025
·
Updated May 20, 2026
1,094 views
11
4.6 (10)
// Memoize with two production guards: an LRU bound on the cache, and a TTL
// per entry. JS Map preserves insertion order, which gives us LRU recency
// 'for free' as long as we re-insert on every hit.
function memoize(fn, { ttlMs = Infinity, maxSize = 1000, keyOf = JSON.stringify } = {}) {
const cache = new Map(); // key -> { value, expiresAt }
return function memoized(...args) {
const key = keyOf(args);
const now = Date.now();
const hit = cache.get(key);
if (hit && hit.expiresAt > now) {
// Refresh recency: delete + set moves the key to the back.
cache.delete(key);
cache.set(key, hit);
return hit.value;
}
const value = fn.apply(this, args);
cache.set(key, { value, expiresAt: now + ttlMs });
// Trim oldest if over budget. Map iterator yields insertion order.
while (cache.size > maxSize) {
const oldestKey = cache.keys().next().value;
cache.delete(oldestKey);
}
return value;
};
}
// Pretend this is a slow lookup.
let calls = 0;
function lookupUser(id) { calls++; return { id, name: `user_${id}` }; }
const memoized = memoize(lookupUser, { ttlMs: 60_000, maxSize: 3 });
memoized(1); memoized(2); memoized(3); memoized(1); // 1 stays warm
memoized(4); // evicts oldest (which is now 2 because 1 was touched)
console.log('calls:', calls); // 4 distinct ids, only 4 underlying calls
console.log('alive:', [...new Set([1,2,3,4]).values()].map(id => [id, memoized(id) && true]));
console.log('total calls:', calls);Two guards do the heavy lifting. The TTL field on each entry handles staleness without a sweep timer, because expiry is checked lazily on read; the entry sits dead until something tries to use it or the LRU evicts it. The LRU bound exploits a trick of Map: iteration order is insertion order, so deleting and re-inserting on a hit moves the entry to the back, making the first key in cache.keys() always the least-recently-used. Real production caches I have shipped use TTL=60s and maxSize=10k as defaults; tune the size to match your service's working set. The default keyOf = JSON.stringify is fine for primitive args; pass a custom keyOf for objects with cycles or non-stable key order.
// The bug in 'cache the resolved value' for async functions: a thundering herd.
// Five callers each call memoizedLookup(123) before the first call resolves;
// without care, each one fires a fresh request. Cache the PROMISE so concurrent
// callers share a single in-flight resolution.
function memoizeAsync(fn, { ttlMs = Infinity, maxSize = 1000 } = {}) {
const cache = new Map(); // key -> { promise, expiresAt }
return async function memoized(...args) {
const key = JSON.stringify(args);
const now = Date.now();
const hit = cache.get(key);
if (hit && hit.expiresAt > now) {
cache.delete(key); cache.set(key, hit);
return hit.promise;
}
// Start the work; immediately seat the promise so concurrent callers wait.
const promise = fn(...args).catch((err) => {
// On error, evict so the next call retries instead of caching the failure.
cache.delete(key);
throw err;
});
cache.set(key, { promise, expiresAt: now + ttlMs });
while (cache.size > maxSize) {
cache.delete(cache.keys().next().value);
}
return promise;
};
}
let underlyingCalls = 0;
async function fetchUser(id) {
underlyingCalls++;
await new Promise((r) => setTimeout(r, 30));
return { id, name: `user_${id}` };
}
(async () => {
const memoized = memoizeAsync(fetchUser, { ttlMs: 1_000 });
// Five concurrent calls for the same id. Should result in ONE fetch.
const results = await Promise.all([1, 1, 1, 1, 1].map((id) => memoized(id)));
console.log('results:', results.length, 'underlying calls:', underlyingCalls);
})();The single-line difference between right and wrong is that the cache holds the promise itself, seated synchronously the moment the first caller arrives. Every subsequent caller within the same tick reads the same pending promise and awaits it. Without this, five concurrent React components asking for the same user trigger five HTTP requests; with it, one. The catch that evicts on rejection matters too: caching a rejected promise means every retry returns the same rejection forever, which is the opposite of what you want. I have shipped this as the default for any client-side data layer.
// Production memoizers need an escape hatch: 'I know this entry is stale, force
// a fresh call'. Build it as a method on the memoized function.
function memoize(fn, { ttlMs = Infinity, maxSize = 1000 } = {}) {
const cache = new Map();
function memoized(...args) {
const key = JSON.stringify(args);
const now = Date.now();
const hit = cache.get(key);
if (hit && hit.expiresAt > now) {
cache.delete(key); cache.set(key, hit);
return hit.value;
}
const value = fn(...args);
cache.set(key, { value, expiresAt: now + ttlMs });
while (cache.size > maxSize) cache.delete(cache.keys().next().value);
return value;
}
memoized.invalidate = (...args) => cache.delete(JSON.stringify(args));
memoized.clear = () => cache.clear();
memoized.peek = () => [...cache.keys()];
return memoized;
}
let calls = 0;
function expensiveStat(userId) { calls++; return { userId, score: Math.floor(Math.random() * 100) }; }
const memoized = memoize(expensiveStat, { ttlMs: 60_000 });
const a = memoized(123);
const b = memoized(123);
console.log('same:', a.score === b.score, 'calls:', calls);
memoized.invalidate(123); // forced refresh
const c = memoized(123);
console.log('refreshed; calls now:', calls);
console.log('peek:', memoized.peek());Three escape hatches make the memoizer usable in real apps: invalidate(args) for surgical busting (a write happened, this one entry is stale), clear() for nuke-the-world (a logout, a global setting changed), and peek() for the inevitable debugging session where you want to see what is in the cache. The cost is three extra lines of method assignment; the benefit is that the memoizer survives contact with real product code. I attach the methods as properties of the function rather than returning a { fn, invalidate } object because the call site (getUser(id) vs cache.fn(id)) reads better, and TS inference still catches incorrect arg shapes for invalidate.
