Memoisation in JavaScript – Unbounded Cache Crashes
After 200,000 entries, an unbounded memoisation cache consumed 300MB and crashed the pod.
- Memoisation caches function results keyed by arguments, trading memory for speed on repeated calls
- Depends on closures to persist the cache across invocations — the cache lives in the wrapper's lexical scope
- Always use Map over plain object: Map preserves key types and prevents silent collisions between
1and'1' - Cache hit latency ~0.01ms; cache miss adds key generation overhead (~0.05ms for simple args)
- Production danger: unbounded caches leak memory — always add LRU eviction or TTL
- Biggest mistake: memoising impure functions — Date.now() or API calls will return stale results forever
Imagine you're a student and your teacher asks you: 'What's 347 times 829?' You work it out on paper — it takes a minute. Now she asks you the same question five minutes later. You don't redo the maths; you just look at your notes. Memoisation is exactly that: your function does the hard work once, writes the answer in a notebook (the cache), and next time the same question arrives it just reads from the notebook instead of working it out again. The function gets faster every time it sees a question it's already answered.
Every senior JavaScript developer has hit the wall where a perfectly correct function becomes a production liability — not because the logic is wrong, but because it's being asked the same question thousands of times per second and recomputing the answer from scratch every single time. In data-heavy UIs, recursive algorithms, and real-time search filtering, this kind of redundant computation quietly kills performance while your profiler screams at you.
Memoisation solves this by turning a pure function into a self-learning one. The first call does the real work. Every subsequent call with identical arguments short-circuits straight to a cached result. It's not magic — it's a deliberate trade-off: you spend memory to buy speed. Understanding exactly when that trade-off pays off, and when it absolutely doesn't, separates developers who reach for memoisation reflexively from those who use it surgically.
By the end of this article you'll be able to write a production-grade memoisation utility from scratch, understand the closure and Map internals that make it tick, handle non-primitive arguments correctly, recognise the subtle bugs that bite even experienced engineers, and explain the whole thing confidently in an interview setting.
How Memoisation Works Under the Hood — Closures and the Cache
Memoisation relies on two JavaScript fundamentals working in tandem: closures and a key-value store (traditionally an object, better as a Map).
When you call a memoisation wrapper around your function, that wrapper creates a cache object in its own scope and returns a new function. That returned function closes over the cache — meaning every future call has access to the same cache, even though the outer wrapper has long since finished executing. This is the closure doing its job.
On each invocation, the inner function serialises its arguments into a cache key, checks whether that key already exists, and either returns the stored result immediately or calls the original function, stores the result, then returns it.
The critical insight is that the cache persists for the lifetime of the memoised function reference. If you create a new memoised function, you get a fresh cache. If you keep a reference to the same memoised function, the cache accumulates results across every call site that uses it.
Using a plain object as the cache is fine for string and number arguments, but it silently converts all keys to strings — meaning the integer 1 and the string '1' collide. A Map avoids this because it uses strict equality for key lookup, which is why production implementations prefer it.
cache[1] and cache['1'] are the same slot — a silent collision that returns wrong results. A Map uses SameValueZero equality, keeping integer 1 and string '1' as completely separate keys. Always use Map for a type-safe cache.Handling Multiple Arguments — The Serialisation Problem
Real functions rarely take a single argument. The moment you add a second parameter, memoisation has a key-generation problem: how do you turn an arbitrary list of arguments into a single, unambiguous cache key?
The naive solution is JSON.stringify(arguments) or joining args with a delimiter. Both have traps. JSON.stringify produces identical output for [1, 11] and [11, 1] if you're not careful — well, actually those serialise differently, but it silently drops functions, undefined values, and circular references without throwing, meaning two logically different argument sets can produce the same key.
The delimiter approach — joining with a pipe character | — breaks when an argument itself contains that delimiter: fn('a|b', 'c') and fn('a', 'b|c') both produce 'a|b|c'.
The most robust production approach is using a nested Map tree (a trie structure), where each argument level corresponds to one Map. This avoids serialisation entirely, uses strict equality, and handles any argument type correctly including objects — as long as you're passing the same object reference each time.
For the common case of JSON-serialisable primitives, a carefully chosen separator that cannot appear in the data (like \0 — the null character) gives you a simple and highly performant key with virtually no collision risk.
memoisedFn({ id: 1 }) — JSON.stringify will correctly match the content, but this hides a nasty edge case: two objects with different property insertion order serialize differently in older engines. Safer still: if your function takes objects, consider canonicalising them (sorting keys) before stringification, or restructure to pass primitives.Recursive Memoisation — Fibonacci and the Stack Trap
Fibonacci is the canonical memoisation example for good reason: its naive recursive form has O(2ⁿ) time complexity because it recomputes the same sub-problems exponentially. With memoisation it drops to O(n). But there's a subtlety most tutorials skip entirely.
If you wrap a recursive function with a memoiser and the function calls itself by its original name internally, the recursive calls bypass the memoised wrapper entirely. The outer call hits the cache correctly, but every internal recursive call goes straight to the unwrapped function — you get no caching benefit on sub-problems.
The fix is to make the function reference itself through the memoised version, not the original. You can achieve this by either: (a) reassigning the function variable to its memoised version before it calls itself, or (b) passing the memoised function into itself as a parameter using a Y-combinator-style approach.
For production-scale Fibonacci or similar dynamic programming problems in JavaScript, an iterative bottom-up approach with a plain array beats memoised recursion on both speed and call-stack safety. Memoised recursion is still liable to hit the engine's call stack limit for inputs above ~10,000 depending on the runtime. Memoisation is most valuable when the recursion tree is deep but narrow — where stack depth stays manageable but repeated sub-problems are abundant.
let f = memoise(function g(n) { return f(n-1) + f(n-2); }).Cache Invalidation, Memory Leaks, and Production Patterns
Memoisation without boundaries is a memory leak waiting to happen. A cache that grows unbounded will quietly consume heap space until Node.js OOMs or the browser tab crashes. In production you need one of two strategies: a TTL (time-to-live) policy that expires stale entries, or a capacity limit using an LRU (Least Recently Used) eviction policy.
An LRU cache evicts the entry that was accessed least recently when capacity is reached. This is the right choice when you have a large input space but a hot subset — your cache stays small and covers the calls that actually matter.
Beyond memory, there's a correctness issue: memoisation is only safe for pure functions — those whose output depends solely on their inputs and which have no side effects. Memoising a function that reads from a database, calls Date.now(), or modifies external state will silently serve stale or wrong results. This is the single most dangerous production misuse of memoisation.
For React developers: useMemo and useCallback are component-scoped memoisation hooks. They do NOT persist across renders beyond the component's lifetime, and their cache size is always 1 — they only remember the most recent call. They solve a different problem (referential stability) more than raw computation speed. Don't conflate them with a general memoisation utility.
Date.now(), generates a random number, or touches any external state — do NOT memoise it. The cache will serve the first result forever, ignoring all real-world changes. Memoisation is a contract: 'same inputs always produce the same output.' Break that contract and you introduce silent, intermittent bugs that are brutal to debug.Performance Benchmarks: When Memoisation Helps vs Hurts
Memoisation isn't free. Every call incurs key-generation overhead, a Map lookup, and the closure's lexical scope access. For functions that are already fast — like a simple arithmetic operation or a string concatenation — the overhead of memoisation can make the overall call slower than recomputing.
As a rule of thumb: if the function's execution time is less than ~1 microsecond, memoisation will likely degrade performance. If it's between 1-10 microseconds, measure. If it's above 10 microseconds and the same arguments repeat, memoisation pays off.
Benchmark your specific use case with . Run 10,000 calls with random arguments and then 10,000 calls with the same repeated argument to see hit/miss costs. A good memoisation utility should show at least 10x speedup on repeated calls for expensive functions.performance.now()
Another hidden cost: memory overhead per entry. A typical cached result with a string key (e.g., 50 chars) and an object value can consume 500+ bytes. Cache 100,000 entries and you're at 50MB before the result data. Profile heap usage with process.memoryUsage() in Node or the Memory tab in Chrome DevTools.
- If the function runs in <1µs, memoisation overhead usually loses you money.
- If the function runs in 1-10µs and repetition rate is high (>50%), it may break even.
- If the function runs >10µs and you see repeated arguments, memoisation is a net win.
- If arguments are never repeated, memoisation is pure overhead — skip it.
- If memory is constrained, use LRU with a tight limit; the investment is bounded.
perf_hooks or browser performance.now() for precise measurement.Unbounded Cache Brings Down an E‑Commerce Product Service
getFilteredProducts(filters) used JSON.stringify(filters) as the key. Every unique combination of filter values created a new cache entry. With thousands of users applying different filters, the cache grew to over 200,000 entries within an hour, consuming 300MB of heap. The unbounded Map never evicted old entries.- Any memoised function whose argument space is larger than your available memory needs a bound — LRU or TTL.
- When the function reads external state (like a database), add a TTL to avoid serving stale data.
- Profile cache size in production — if you don't measure it, you don't know if it's leaking.
Date.now()? If so, memoisation is the wrong pattern. Remove the cache.memoise()args.join('\0'), ensure the separator does not appear naturally in argument values. If using JSON.stringify, verify all arguments are serialisable (no undefined, functions, or circular references).JSON.stringify outside the functionKey takeaways
Common mistakes to avoid
5 patternsMemoising a function that closes over external mutable state
Using a plain object as the cache and passing numeric keys
1 and string '1' when they should produce different outputs. Hard to spot because the values happen to be equal for your data.===), preserving type differences.Memoising a method on a class instance without binding `this` correctly
Cannot read properties of undefined when called, or operates on the wrong object context because the wrapper loses this..apply(this, args) to forward the correct context. Alternatively, bind the method before wrapping: this.compute = memoise(this.compute.bind(this)).Assuming `useMemo` is equivalent to a general memoisation utility
useMemo expecting it to cache results across different argument values, but it recomputes every render when the dependency array changes.useMemo has a cache size of 1 — it only remembers the last computed value for a given dependency array. For multiple distinct inputs, write a dedicated memoisation wrapper or use useRef + manual cache.Not invalidating the cache when the underlying data changes
Interview Questions on This Topic
Can you implement a memoisation function from scratch in JavaScript, and explain what data structure you'd use for the cache and why — specifically why Map is preferable to a plain object?
javascript
function memoise(fn) {
const cache = new Map();
return function(...args) {
const key = args.join('\0');
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
`
I use Map over a plain object because Map preserves the type of the key. A plain object coerces all keys to strings, so cache[1] and cache['1'] collide. Map uses SameValueZero equality, keeping integer 1 and string '1' as separate keys. Map also provides .size and .has() which are more reliable than checking key in obj`.Frequently Asked Questions
That's Advanced JS. Mark it forged?
6 min read · try the examples if you haven't