Community JavaScript Snippet
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.
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)
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 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.
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.
