memoizeLast()
intermediateA single-slot memoizer that remembers only the previous call — the pattern behind React's useMemo.
Implement a memoize function in JavaScript — cache-key design, the multi-argument collision trap, exposed caches, WeakMap keys, and when memoization backfires.
Implement
memoize(fn): calling the wrapper with arguments it has seen before returns the cached result instead of re-runningfn.
The loop-and-Map is easy; the question is the cache key. Every interesting follow-up — collisions, object arguments, memory growth, custom resolvers — is a key-design question wearing a different hat. Lead with that framing and the interview follows your structure instead of the other way around.
function memoize(fn, resolver) {
const cache = new Map();
function memoized(...args) {
// Key policy: resolver if given, else FIRST ARGUMENT (lodash's default)
const key = resolver ? resolver.apply(this, args) : args[0];
if (cache.has(key)) {
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
}
memoized.cache = cache; // expose it — inspection, testing, targeted eviction
return memoized;
}let calls = 0;
const square = memoize((n) => { calls++; return n * n; });
square(4); // computes → 16 (calls = 1)
square(4); // cache hit → 16 (calls = 1)
square(5); // computes → 25 (calls = 2)Why Map over a plain object: any key type (objects, NaN via SameValueZero, no "__proto__" landmines), a real size, and clean iteration for eviction. Why expose .cache: lodash does, and it converts an opaque optimization into a debuggable, evictable one — square.cache.delete(4) is targeted invalidation without a rebuild.
const add = memoize((a, b) => a + b);
add(1, 2); // 3 — cached under key 1
add(1, 99); // 3 ❌ — key 1 hits; b never looked atThis is lodash's actual documented behavior and the most common real-world memoize bug. The fixes are all resolver policies:
memoize((a, b) => a + b, (a, b) => `${a}|${b}`); // fine for primitives
memoize(fn, (...args) => JSON.stringify(args)); // objects too — but see belowJSON.stringify as a resolver inherits every stringify asymmetry: key-order sensitivity ({a,b} vs {b,a} miss), silently dropped undefined/functions (different args, same key — the dangerous direction), and cost proportional to argument size. For object identity as the key, skip serialization entirely — pass the object itself into the Map (reference equality), or a WeakMap when the cached entry should die with the object:
function memoizeByRef(fn) {
const cache = new WeakMap(); // entries GC'd when the key object is unreachable
return (obj) => {
if (cache.has(obj)) return cache.get(obj);
const result = fn(obj);
cache.set(obj, result);
return result;
};
}The WeakMap variant is the answer to "won't the cache leak?" for object-keyed memoization — the cache can't outlive its keys, by construction. (For primitive keys there's no weak option; that's what eviction is for.)
const add = memoize((a, b) => a + b);
console.log(add(1, 2), add(1, 99));The follow-up that separates users from operators:
Date.now-dependent or stateful functions caches stale answers; memoize is only sound for pure computations. The async variant relaxes this with TTLs precisely because network responses aren't pure.Map iteration order makes a minimal LRU ~10 lines — evict cache.keys().next().value).Map lookup can cost more than the function; memoizing x * 2 is a pessimization. Measure, don't assume.add(1, 2) / add(1, 99) verbatim.fn — nothing is cached (the throw skips set), so the next call retries — usually right for computations; contrast with once's at-most-once policy fork.this forwarding — both fn.apply(this, ...) and resolver.apply(this, ...); memoized methods keyed per-instance need this in the key (or a per-instance cache) — a genuinely sneaky bug.undefined results are cached — cache.has, never cache.get(...) !== undefined, or falsy results re-compute forever.NaN keys — Map uses SameValueZero, so NaN hits NaN — a free correctness win over ===-based schemes (the equality-algorithms story).cache[key] ||) instead of has — falsy results never cache.args.join(",") keys — ["a,b"] and ["a","b"] collide; objects collapse to "[object Object]".React.useMemo as this function — it's single-slot, previous-args-only, a different (and deliberately smaller) contract.Map insertion order: on hit, delete + re-set to mark recent; on insert over cap, evict the oldest (keys().next().value).fn; const fib = memoize((n) => n < 2 ? n : fib(n-1) + fib(n-2)) works because the closure references the wrapper. Subtle and beloved by interviewers.{ value, expiresAt } and check on read — lazy expiry, no timers; sweep only if memory demands it.useMemo/React.memo (memoizeLast semantics), HTTP caches, and CSS content-visibility-style render caching — knowing which layer already caches saves you from double-caching bugs.A single-slot memoizer that remembers only the previous call — the pattern behind React's useMemo.
Guarantee a function runs exactly once and returns its cached result forever after.
Pre-fill leading arguments of a function — partial application, and how it differs from currying.
Compose functions left-to-right into a pipeline — the one-liner with a lot of interview depth.