Memoize caches a function's return values keyed by its arguments. The base version is straightforward — wrap the function, check a cache before calling. But the interview gets interesting when you add a custom key resolver for multi-arg and object-argument functions.
What is memoize()?
memoize(fn) returns a wrapped version of fn that caches return values by input arguments. The first call with a given argument set runs fn and stores the result; every subsequent call with the same arguments returns the cached value instantly, skipping fn entirely.
This is the canonical time-for-space trade-off: you burn memory to avoid re-computation. It's only worth doing when fn is expensive (heavy computation, network calls) and called repeatedly with the same inputs.
The core mechanic is a closure with a cache (usually a Map or plain object). On each call, serialize the arguments into a cache key, check if the result exists, and return it if so. Otherwise, call fn, store the result, and return it.
Real-world use cases:
- Expensive calculations — Fibonacci, factorial, or any recursive math with overlapping subproblems
- Derived data — computing
fullNamefromfirstName+lastNamein a component that re-renders often - Selector functions in state management (Redux's
createSelectoris memoization) - Parsing — memoize
JSON.parse()or template compilation by input string
The interview escalates with a custom key resolver: when fn takes multiple arguments or object arguments, the default cache key (arguments[0]) isn't enough. You need a resolver function that maps (...args) to a unique cache key. This tests whether you understand cache key design and can generalize from a single-argument cache to arbitrary signatures.
The Problem
"Implement memoize(fn) that returns a memoized version of fn. The memoized function should cache results by argument and return the cached value on subsequent calls with the same argument."
The interviewer extends:
"Now add a resolver parameter — a custom function that generates the cache key from the arguments. This handles multi-argument functions and object arguments."
Thought Process
A memoized function is a closure around a cache (typically a Map). On each call:
- Compute the cache key from the arguments
- If the key is in the cache, return the cached value
- Otherwise, call the original function, store the result, return it
Map is the right data structure here: O(1) lookup, and keys can be anything — not just strings.
For multi-argument functions, the default key strategy is JSON.stringify(args). But a custom resolver function gives the caller control.
Step 1 — Base: Single-Argument Memoize
Loading editor...
Step 2 — Why the Default Key Breaks for Multiple Arguments
Loading editor...
Map only uses the first argument as the key. For multi-arg functions, we need a key that captures all arguments.
Step 3 — Adding a Custom Resolver
Loading editor...
The custom resolver pattern: pass a function that extracts a stable identifier from the arguments. For database records, use the ID. For computation, use JSON.stringify on the relevant portion.
Step 4 — Handling Object Arguments
The interviewer asks: "What happens if I use an object as a cache key without a resolver?"
Loading editor...
Step 5 — Edge Cases
NaN as a key: Map uses SameValueZero comparison. NaN is treated as equal to NaN in Map (unlike ===). This means memoize(fn)(NaN) works correctly — subsequent calls with NaN hit the cache.
undefined return value vs cache miss: cache.has(key) distinguishes "key exists with value undefined" from "key doesn't exist." Never use cache.get(key) to check for cache hits — use has().
Unbounded cache growth: A real memoize implementation should limit cache size. Mention LRU eviction as the standard solution:
this context: The memoized function forwards this with fn.apply(this, args). This is important when memoizing prototype methods.
Step 6 — In-Flight Deduplication (Production Pattern)
The interviewer asks: "What if two callers request the same key before the first async call resolves? You'd fire the function twice."
Standard memoize caches results — but if fn is async and takes 500ms, a second call during that window misses the cache and fires a duplicate request. The fix: cache the promise itself while it's in-flight.
Loading editor...
This is the pattern used in data-fetching libraries (React Query, SWR) to prevent duplicate network requests.
Full Solution
Loading editor...
What Interviewers Are Testing
- Cache data structure choice —
Mapover plain object (Map handles any key type, avoids__proto__injection, has O(1)has()) - Key generation — understanding that default single-arg keying breaks for multi-arg and object-arg functions
- Resolver pattern — the ability to inject a custom key function for flexible caching strategies
has()vsget()— usinghas()to distinguish cache miss from cachedundefined- Memory awareness — acknowledging unbounded cache growth and mentioning LRU as the fix
- In-flight deduplication — caching the promise itself, not just the result, to prevent duplicate concurrent requests
Complexity
| Time | Space | |
|---|---|---|
| Cache hit | O(1) | O(N) — N = cache entries |
| Cache miss | O(T) of fn | O(N) |
Interview Tips
- Choose
Mapand explain why — "I'm usingMapinstead of a plain object because it handles any key type, avoids prototype pollution, and has clearerhas()semantics." - Write single-arg first, then show it breaking — implement the simple version, then demonstrate
memoAdd(2, 3)followed bymemoAdd(2, 4)returning the wrong answer. This proves you understand the limitation. - Mention LRU unprompted — after presenting the base solution, say "in production, I'd add a
maxSizeoption with LRU eviction to prevent memory leaks from unbounded caching." - Use a realistic resolver example —
resolver: (user) => user.idis more convincing thanJSON.stringify. It shows you think about real API responses, not just toy arguments.