InterviewsVector
intermediateVery common5 min read · Updated Jul 18, 2026

Implement memoize(): Cache by Argument Key

Implement a memoize function in JavaScript — cache-key design, the multi-argument collision trap, exposed caches, WeakMap keys, and when memoization backfires.


The problem

Implement memoize(fn): calling the wrapper with arguments it has seen before returns the cached result instead of re-running fn.

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.

Implementation (lodash-style)

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.

The trap: the default key ignores every argument but the first

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 at

This 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 below

JSON.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.)

Test yourself

Predict the outputWhat does this code print?
const add = memoize((a, b) => a + b);
console.log(add(1, 2), add(1, 99));

When memoization backfires

The follow-up that separates users from operators:

  • Impure functions — memoizing 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.
  • Unbounded growth — one entry per distinct key, forever. High-cardinality keys (user IDs, coordinates) = a slow leak wearing a speedup costume. Fix: LRU cap (Map iteration order makes a minimal LRU ~10 lines — evict cache.keys().next().value).
  • Cheap functions — key construction + Map lookup can cost more than the function; memoizing x * 2 is a pessimization. Measure, don't assume.
  • Results shared by reference — every hit returns the same object; one caller mutating it corrupts all others — same aliasing hazard as shared API-cache data.

Edge cases interviewers probe

  • Multi-arg default-key collision — the trap above; interviewers plant add(1, 2) / add(1, 99) verbatim.
  • A throwing 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 cachedcache.has, never cache.get(...) !== undefined, or falsy results re-compute forever.
  • NaN keysMap uses SameValueZero, so NaN hits NaN — a free correctness win over ===-based schemes (the equality-algorithms story).

Common mistakes

  • Truthiness (cache[key] ||) instead of has — falsy results never cache.
  • args.join(",") keys — ["a,b"] and ["a","b"] collide; objects collapse to "[object Object]".
  • No eviction story for long-lived processes.
  • Memoizing impure functions and debugging "stale data" for a day.
  • Treating React.useMemo as this function — it's single-slot, previous-args-only, a different (and deliberately smaller) contract.

Follow-up questions

  • "Add LRU with max N entries."Map insertion order: on hit, delete + re-set to mark recent; on insert over cap, evict the oldest (keys().next().value).
  • "Memoize a recursive function like fib?" — the recursive calls must go through the memoized wrapper, not the raw 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.
  • "Memoize async functions?" — cache the promise (dedupes in-flight calls) and evict on rejection — the full treatment.
  • "TTL per entry?" — store { value, expiresAt } and check on read — lazy expiry, no timers; sweep only if memory demands it.
  • "Where does the platform do this for you?"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.

  • memoizeLast()

    intermediate

    A single-slot memoizer that remembers only the previous call — the pattern behind React's useMemo.

  • _.once()

    beginner

    Guarantee a function runs exactly once and returns its cached result forever after.

  • _.partial()

    intermediate

    Pre-fill leading arguments of a function — partial application, and how it differs from currying.

  • pipe()

    intermediate

    Compose functions left-to-right into a pipeline — the one-liner with a lot of interview depth.