You are asked to write a general-purpose `memoize(fn)` wrapper for a production codebase. How do you build the cache key, and what makes memoization unsafe for a given function?
answer
- only pure, deterministic functions qualify
- key design is the whole problem
- stringify collides and misses
- Map plus has, not truthiness
- unbounded cache is a slow leak
basics
~20 sMemoize only pure, deterministic functions. Key a single primitive argument directly in a Map; for several arguments or object arguments you need an explicit key function, because stringifying arguments both collides and misses. Bound the cache — an unbounded one grows for the life of the process.
solid answer
~50 sStart by asking whether the function is a legitimate candidate: it must be deterministic and free of observable side effects, or caching turns into a correctness bug. Then design the key deliberately. One primitive argument keys straight into a `Map`. Several arguments, or object arguments, need a key function you choose — `JSON.stringify(args)` is the usual shortcut, but it collides (`{a: undefined}` and `{}` both stringify to `{}`), it is order-sensitive so equivalent objects miss, and it cannot represent functions, `Symbol`s or cycles. If callers pass the same object identity repeatedly, a `WeakMap` keyed on that object avoids serialisation and lets entries be collected. Finally, bound the cache: a plain `Map` in a long-running process only grows, so add a size cap with eviction, or accept unboundedness only for a fixed, small input domain. Also decide what happens when `fn` throws or returns a rejected promise — caching a failure forever is rarely what you want.
code
javascript · 17 linesfunction memoize(fn, keyFor = (args) => args[0], maxSize = 500) {
const cache = new Map();
return (...args) => {
const key = keyFor(args);
if (cache.has(key)) return cache.get(key); // has(): caches undefined correctly
const value = fn(...args);
cache.set(key, value);
if (cache.size > maxSize) {
cache.delete(cache.keys().next().value); // evict oldest insertion
}
return value;
};
}
// the stringify trap the default key avoids
console.log(JSON.stringify([{ a: undefined }]) === JSON.stringify([{}])); // true
console.log(JSON.stringify([{ a: 1, b: 2 }]) === JSON.stringify([{ b: 2, a: 1 }])); // falsego deeper
Know what memoization is and that it only makes sense for a function whose output depends solely on its arguments. Be able to write the one-argument Map version.
Explain the key-construction problem concretely: why one primitive argument is easy, why object arguments compare by identity in a Map, and why stringifying arguments both collides and misses.
Demonstrate production judgment: interrogate purity before wrapping, choose an explicit key function, bound the cache with eviction, and state what happens on throw or promise rejection.
Own the systems tradeoff — where the cache belongs at all (in-process versus a shared tier), what staleness the business can absorb, and how per-process memoization interacts with fleet size, deploys and memory limits.
## The naive version, and why it is only a starting point ```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; }; } ``` This is correct for one argument that is a primitive, and it already shows the two habits worth having: use `Map` rather than a plain object (a `Map` accepts any key type and cannot collide with inherited property names), and use `has` rather than a truthiness check, so a legitimately cached `undefined`, `0`, `''` or `null` is not recomputed forever. `Map` keys compare with SameValueZero, so `NaN` works as a key, and `0` and `-0` are the same key. Object arguments compare by identity, which is exactly right when callers reuse the same object and exactly useless when they build a fresh equivalent one per call. ## Building keys for several arguments There is no universally correct key function, which is why a good answer refuses to pretend otherwise. The common shortcut has three named failure modes: ```js const key = JSON.stringify(args); ``` - **Collisions.** `JSON.stringify([{a: undefined}])` and `JSON.stringify([{}])` are both `"[{}]"`, so two different calls share a cache entry and one gets the other's answer. Functions and `undefined` values vanish the same way, and a `Date` serialises to the same string as the equivalent ISO string. - **Misses.** Property order is preserved in the output, so `{a: 1, b: 2}` and `{b: 2, a: 1}` produce different keys for equal inputs — a silent cache miss that makes the memo look useless under load. - **Failures.** A cyclic structure throws `TypeError`, and `BigInt` throws too. A cache layer that can crash the call it was meant to speed up is a poor trade. So make the key function a parameter — `memoize(fn, keyFor)` — and let each call site pick. When arguments are a small fixed set of primitives, a delimiter join is faster and safer than `JSON.stringify`. When the argument is an object the caller holds onto, a `WeakMap` keyed on the object avoids serialisation entirely and lets the entry disappear when the object does. When arguments have a natural identifier — a user id, a URL — key on that and nothing else. ```js function memoize(fn, keyFor = (args) => args[0]) { const cache = new Map(); return (...args) => { const k = keyFor(args); if (cache.has(k)) return cache.get(k); const value = fn(...args); cache.set(k, value); return value; }; } ``` ## When memoization is unsafe Caching is only sound when `fn` is a *function* in the mathematical sense: same inputs, same output, no observable effects. Concretely, do not memoize when: - **The result depends on state outside the arguments** — a mutable module variable, the current time, a random value, a database. The cache freezes a snapshot and the code starts reporting stale answers with no error anywhere. - **The function has side effects worth repeating** — logging, incrementing a counter, writing to storage. Memoizing silently deletes those effects from every call after the first. - **The returned value is mutable and callers mutate it.** Every caller receives the same object reference, so one caller's mutation is visible to all the others. Either return a defensive copy or freeze the value. ## Bounding and lifetime A `Map`-backed cache in a long-lived process never shrinks. If the key space is small and closed (thirty locale codes, ten configuration names) that is fine and simple. If it is open — user ids, arbitrary URLs, search strings — the cache is an unbounded retention of every value ever computed, and the process dies eventually. Add a cap with an eviction policy (an LRU is the standard choice; a `Map` iterates in insertion order, which makes a crude LRU easy to write), or a time-to-live if freshness matters more than hit rate. `WeakMap` gives you collection for free but only when the key is the object whose lifetime should govern the entry. ## Errors and promises Decide explicitly what a thrown error does. The default in the code above is that nothing is cached — the `set` never runs, so the next call retries. That is usually right. For an async function, the value you cache is the promise, which means a rejection is cached too and every later caller receives the same failed promise. If retry is wanted, delete the entry when the promise rejects. ## What the interviewer is checking Not whether you can write a `Map` lookup — everyone can. They want to hear you interrogate the *function* before wrapping it, name a concrete key-collision case, and volunteer the unbounded-growth problem before being prompted. "It depends on the key function, and here is the one I would use for these arguments" is the answer that lands.
- Why use a Map with `has` rather than a plain object with a truthiness check?A `Map` accepts keys of any type, keeps insertion order, cannot collide with inherited names like `toString` or `__proto__`, and reports its size directly. `has` matters independently: a truthiness check treats a cached `undefined`, `0`, `''` or `null` as a miss, so those inputs recompute on every single call and the memo silently does nothing for them.
- When is a WeakMap the right cache for a memoize wrapper?When the single argument is an object the caller already holds, and the cached value should live exactly as long as that object. The entry becomes collectable once the key object is unreachable, so the cache cannot grow without bound. It does not work for primitive arguments, for multi-argument keys, or when you need to enumerate or size the cache.
- What happens if you memoize an async function and the underlying call fails?You cache the rejected promise, so every later caller for that key receives the same failure forever — a transient network blip becomes permanent. If retry is wanted, attach a handler that deletes the cache entry on rejection before rethrowing, so the next call recomputes.
- How would you decide the cache bound in practice?By the key space and the value size. A closed, small domain needs no bound at all. An open domain — ids, URLs, user text — needs a cap with LRU eviction, or a TTL when staleness is the bigger risk. Then measure: a hit rate near zero means the key function is wrong, not that the cache is too small.
saying these in an interview costs you the question
- Memoizes a function that reads mutable external state
- Uses JSON.stringify keys without naming a collision case
- Uses cache[key] truthiness so cached undefined never hits
- Assumes the cache is cleared automatically by the collector
- Ignores unbounded growth in a long-running process
- Caches a rejected promise and never allows a retry