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.

JavaScript
Frontend
3 snippets
memoization
lru-cache
ttl
jordandubois

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.