Memoization
Memoization is an optimization technique that caches the result of a function call keyed by its arguments, so that repeating the same call returns the stored result instead of recomputing it. It trades memory for speed and shines whenever a function is pure, deterministic, and expensive — think recursive algorithms, heavy parsing, or anything called repeatedly with the same inputs. Because it relies on the cached result always matching what a fresh call would produce, memoization is only safe for functions with no side effects and stable outputs.
Why memoization works
A memoized function wraps an original function in a closure that holds a cache. On each call it builds a key from the arguments, checks the cache, and either returns the hit or computes, stores, and returns a miss. This only makes sense when the same arguments always map to the same output — which is exactly the definition of a pure function.
const slowSquare = (n) => {
for (let i = 0; i < 1e8; i++); // simulate expensive work
return n * n;
};
console.time("first");
slowSquare(9); // does the heavy loop
console.timeEnd("first");
A reusable memoize helper
The cleanest implementation is a higher-order function: it takes any function and returns a memoized version. Use a Map for the cache rather than a plain object — Map keeps insertion order, accepts any key type, and avoids prototype-pollution surprises from keys like "constructor".
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
const square = memoize((n) => {
console.log(`computing ${n}`);
return n * n;
});
console.log(square(4)); // computes
console.log(square(4)); // cached, no log
console.log(square(5)); // computes
Output:
computing 4
16
16
computing 5
25
Choosing a key strategy
The cache key must uniquely and consistently represent the arguments. The right strategy depends on what your function receives.
| Strategy | Key code | Best for | Caveat |
|---|---|---|---|
| Single primitive | args[0] | One number/string arg | Breaks with multiple args |
| Join primitives | args.join("|") | Several primitive args | Ambiguous if values contain the separator |
| JSON serialize | JSON.stringify(args) | Mixed/object args | Order-sensitive; drops functions & undefined |
Nested Map | per-arg lookup | Object identity caching | More code; uses reference equality |
Warning:
JSON.stringifyignoresundefined, functions, and symbols, and serializes objects by content in property order.{a: 1, b: 2}and{b: 2, a: 1}produce different keys. For object-identity caching, prefer aWeakMapso unreferenced keys can be garbage-collected.
Memoizing recursive functions
Recursion is where memoization pays off most, because subproblems repeat exponentially. A naive Fibonacci recomputes the same values an absurd number of times; memoizing collapses it to linear work — but you must memoize the recursive call itself, not just the outer entry point.
const fib = memoize((n) => (n < 2 ? n : fib(n - 1) + fib(n - 2)));
console.log(fib(40)); // 102334155, near-instant
Because fib refers to the memoized binding inside its own body, every internal subcall hits the cache. Wrapping a function that recurses through a different name would leave the inner calls uncached.
Cache invalidation caveats
Memoization is famous for being one of the “two hard problems” in computer science precisely because the cache can outlive its correctness. Keep these in mind:
- Never memoize impure functions. If the function reads the clock, random values, the DOM, or external state, cached results go stale immediately.
- The cache grows unbounded. A long-lived memoized function with many distinct inputs is a memory leak. Cap the size (e.g. an LRU eviction policy) or use a
WeakMapkeyed by objects. - Mutable return values are shared. Callers receive the same cached object reference, so a mutation by one caller corrupts the result for all others. Return frozen or freshly cloned data — see immutability.
function memoizeLimited(fn, maxSize = 100) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
if (cache.size > maxSize) {
cache.delete(cache.keys().next().value); // evict oldest
}
return result;
};
}
Tip: For caching by a single object argument, swap the
Mapfor aWeakMap. Entries vanish automatically once the key object is no longer referenced anywhere else, sidestepping the unbounded-growth problem entirely.
Best practices
- Only memoize pure, deterministic functions; anything with side effects or external dependencies is unsafe.
- Memoize when the function is genuinely expensive and called repeatedly with repeated inputs — otherwise the bookkeeping costs more than it saves.
- Pick a key strategy that matches the argument shapes; reserve
JSON.stringifyfor serializable arguments and be aware of its ordering andundefinedquirks. - Bound the cache (size limit or
WeakMap) to prevent memory leaks in long-running processes. - Return immutable or cloned values so callers cannot mutate a shared cached object.
- For recursion, ensure the recursive calls go through the memoized binding, not the original function.