skip to content

A memoize() helper in JavaScript keeps its cache in a closure. What memory questions would you settle before shipping it into a long-running process?

level: principalimportance: nice to knowfreq 22%

answer

  1. the cache lives as long as the closure
  2. growth follows key distinctness, not call volume
  3. entries weigh their whole reachable graph
  4. WeakMap keys must be objects, matched by identity
  5. scoping the cache can replace eviction policy

basics

~20 s

Settle four things: whether the cache is bounded in size or age, what each entry transitively retains, whether object arguments should be held weakly so callers can be collected, and how long the memoized function itself lives.

solid answer

~60 s

A memoized function is a closure whose captured cache lives exactly as long as the function does, so a module-level `memoize` in a long-running process is a deliberately permanent structure — and every entry retains both the key and the whole object graph reachable from the cached value. The decisions I would force before shipping are: a bound (max entries with an eviction rule, a TTL, or both, since an unbounded `Map` keyed by stringified arguments only ever grows); the retention weight of a value (a cached response object may pin far more than the caller expects); keying (if the arguments are objects and the cache should not outlive them, a `WeakMap` keyed by the object holds the key weakly, which a `Map` never does); and lifetime scoping (a cache created per request or per instance disappears with its owner, which is often the right answer instead of tuning eviction). I also check async caches: storing the promise is usually right, but a cached rejected promise pins its error and stack.

code

javascript · 18 lines
javascript
function memoizeBounded(fn, max = 500) {
  const cache = new Map(); // Map preserves insertion order
  return (key) => {
    if (cache.has(key)) {
      const hit = cache.get(key);
      cache.delete(key);
      cache.set(key, hit); // refresh recency
      return hit;
    }
    const value = fn(key);
    cache.set(key, value);
    if (cache.size > max) cache.delete(cache.keys().next().value);
    return value;
  };
}

const slow = memoizeBounded((n) => n * 2, 2);
console.log(slow(1), slow(2), slow(3), slow(1));

go deeper

for a junior

Know that a memoized function keeps its cache alive for as long as the function exists, so a cache that never evicts anything only ever grows in a process that keeps running.

for a middle

Be able to explain the two mechanisms behind the growth: keys accumulate with input distinctness, and each entry keeps its value's whole object graph reachable rather than just a small result.

for a senior

Show you can pick a policy for a real workload: bound versus TTL versus request-scoping, keyed by value versus by object identity, and what you would do about caching an in-flight promise that later rejects.

for a principal

Own the framing that a cache is a lifetime and ownership decision, not a performance tweak: ask whether the key space is closed, make the retention ceiling explicit in the design, and resist non-deterministic mechanisms where a guarantee is required.

## The cache is the closure ```js function memoize(fn) { const cache = new Map(); return (arg) => { if (cache.has(arg)) return cache.get(arg); const value = fn(arg); cache.set(arg, value); return value; }; } ``` The returned function holds the scope containing `cache`, so `cache` lives exactly as long as that function value. Assign it to a module-level binding and you have declared, in one line, that this map exists for the lifetime of the process. That is not wrong — it is the point of memoization — but it is a lifetime decision, and it deserves to be made rather than inherited. ## Question 1: what bounds it? An unbounded map keyed by argument values grows monotonically with the *distinctness* of the input, not with its volume. Memoizing a pure function over a handful of enum values is fine forever; memoizing over user ids, URLs, or serialized filter objects is a growth curve. The choices are a maximum entry count with an eviction policy (least-recently-used being the usual default), a time-to-live so entries expire, or both. Each has a cost: LRU needs recency bookkeeping, TTL needs a clock check on read, and neither is free to reason about under concurrency-shaped access patterns. The question I actually ask first is whether the key space is *closed*. If it is, no bound is needed and adding one is complexity. If it is open, an unbounded cache is a decision to grow without limit, and that should be written down rather than implied. ## Question 2: what does one entry weigh? Cache sizing is usually discussed in entry counts, which is the wrong unit. An entry retains its key, its value, and everything transitively reachable from both. A cached parsed document, a response object that closes over its request, or a value that itself holds a callback can make a "1000-entry cache" mean hundreds of megabytes. When entries are heavy and re-derivable, caching a compact projection instead of the whole value is often the better trade than tuning the bound. ## Question 3: how are object arguments keyed? If the arguments are objects and the cached result is meaningful only while that object is alive, a `WeakMap` is the right structure: it holds its keys weakly, so an entry stops keeping its key reachable and disappears once the key is collected. ```js function memoizeByInstance(fn) { const cache = new WeakMap(); // keys must be objects return (obj) => { if (cache.has(obj)) return cache.get(obj); const value = fn(obj); cache.set(obj, value); return value; }; } ``` Two constraints decide whether this applies. `WeakMap` keys must be objects, so it does nothing for a cache keyed by strings or numbers — the common case of memoizing over ids. And it keys by identity, so two structurally identical objects are two entries; a cache that must hit across equal-but-distinct arguments needs a value key and therefore a bound instead. ## Question 4: how long should the cache itself live? Often the cleanest bound is not eviction but scope. A cache created per request, per connection, or per component instance is reclaimed wholesale when its owner is, and needs no policy at all. Reaching for a global memo when a request-scoped one would do is the most common way a small optimization turns into a permanent structure. The trade is hit rate: shorter-lived caches lose the cross-request reuse that motivated caching in the first place, so this is a genuine judgment call rather than a rule. ## Async caches Caching the promise rather than the resolved value is usually right, because it deduplicates in-flight work as well as completed work. The retention wrinkle: a stored rejected promise keeps the rejection value — typically an `Error` with a captured stack, which itself may reference the frames' data — alive as long as the entry lives, and it turns a transient failure into a permanent one. The usual policy is to delete the entry on rejection. ## Where WeakRef and FinalizationRegistry fit `WeakRef` (ES2021) lets an entry hold its *value* weakly, so the cache stops being the reason a value is alive, and `FinalizationRegistry` can notify you after a collection so you can drop the now-empty entry. Both are explicitly non-deterministic: the specification does not promise a callback ever runs, or when. They are legitimate for caches whose entries are re-derivable and inappropriate for anything the program's correctness depends on. Reaching for them before a plain bound is a warning sign, not sophistication. ## What I want to hear from a candidate Not a preferred library. I want the framing that a memo cache is a lifetime decision with a retention weight, and a candidate who asks whether the key space is closed before proposing an eviction policy.

  • Why does switching to a WeakMap not help a cache keyed by user ids?
    Because `WeakMap` keys must be objects. A string or number key is not a valid key at all, and even if you wrapped it, the wrapper would have to be kept alive by someone else for the entry to be findable — which defeats the point. Value-keyed caches need an explicit bound: a maximum size with eviction, a TTL, or a scope that ends.
  • You cache the promise rather than the value. What retention risk does that add?
    A settled promise keeps its settlement value alive for as long as the entry lives. For a rejection that means the `Error` and its captured stack are retained indefinitely, and worse, every future caller gets the stale failure. The standard policy is to remove the entry in a rejection handler so failures are not memoized, and to keep the promise only for the in-flight deduplication it buys you.
  • When would you argue against adding any cache bound at all?
    When the key space is closed and small — a lookup over a fixed enum, a per-locale formatter, a compiled pattern per known route. There the cache size has a hard ceiling set by the domain, and an eviction policy adds bookkeeping and a cache-miss path for no benefit. The discipline is to state the ceiling explicitly, so a later change that opens the key space is visibly a decision.
  • Is FinalizationRegistry a reasonable way to evict cache entries?
    Only for caches that tolerate losing entries. The specification does not guarantee a cleanup callback ever runs, or when, and behaviour differs across engines and shutdown paths. It can tidy up entries whose values were held via `WeakRef`, but any policy that must hold — a size ceiling, a freshness guarantee — needs a deterministic mechanism instead.

saying these in an interview costs you the question

  • Treats an unbounded Map cache as safe because entries are small
  • Sizes a cache by entry count without asking what each entry retains
  • Suggests WeakMap for a cache keyed by strings or numbers
  • Relies on FinalizationRegistry for a guaranteed eviction
  • Memoizes rejected promises, making a transient failure permanent

context