Memoisation in JavaScript – Unbounded Cache Crashes
After 200,000 entries, an unbounded memoisation cache consumed 300MB and crashed the pod.
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- 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
Memoisation is an optimisation that stores the result of a function call and returns the cached result when the same input reoccurs. It transforms a pure, deterministic function into one that trades memory for speed. The core contract: same input always yields same output, so recomputation wastes cycles.
This works only for pure functions — those with no side effects and no reliance on external state. If a function reads global variables, calls random, or mutates arguments, memoisation returns stale or wrong values. Impure functions break the cache contract silently.
Before reaching for memoisation, verify your function is a pure, idempotent computation. Otherwise, you introduce subtle, hard-to-debug data corruption. Memoisation is not a generic speed-up; it’s a precision tool for pure, repeatable work.
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.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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.
Memoisation: Caching Function Results by Input
Memoisation is an optimisation technique that stores the result of a function call keyed by its arguments, so that subsequent calls with the same arguments return the cached value instead of re-executing the function. The core mechanic: a lookup table (usually a Map or object) maps input tuples to output values. This trades memory for compute time, turning repeated O(n) or O(2^n) operations into O(1) lookups after the first call.
In practice, memoisation works only for pure functions — functions whose output depends solely on their inputs and have no side effects. The cache must be scoped appropriately: per-instance, per-module, or global. A common pattern is to wrap the original function with a closure that holds the cache. The key insight: the cache grows unbounded unless you enforce a limit (LRU, TTL, or max size). Without that, a long-running process will leak memory.
Use memoisation when you have expensive, deterministic, repeated calls — for example, recursive Fibonacci, dynamic programming subproblems, or API response caching in a serverless function. It's not for I/O-bound work (use a dedicated cache like Redis) or for functions with side effects. The real-world impact: a single memoised call can reduce latency from seconds to microseconds, but an unbounded cache in a Node.js server can crash the process with an out-of-memory error.
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.
// A foundational memoisation utility using a Map for type-safe key storage. // This version handles single-argument functions to keep the mechanics visible. function memorise(expensiveFunction) { // The cache lives inside this closure — it persists across all future calls // to the returned memoisedFunction, but is invisible to the outside world. const resultCache = new Map(); return function memoisedFunction(argument) { // Check whether we've already computed a result for this exact argument. if (resultCache.has(argument)) { console.log(`[CACHE HIT] argument=${argument}`); return resultCache.get(argument); // Return the stored result immediately } console.log(`[CACHE MISS] argument=${argument} — computing now...`); // We haven't seen this argument before, so run the real function. const computedResult = expensiveFunction(argument); // Store the result so the next identical call skips this work entirely. resultCache.set(argument, computedResult); return computedResult; }; } // Simulates an expensive calculation — in reality this could be a complex // mathematical transform, a tree traversal, or a regex-heavy string parse. function computeSquare(number) { return number * number; } const memoisedSquare = memorise(computeSquare); console.log(memoisedSquare(6)); // First call — must compute console.log(memoisedSquare(6)); // Second call — served from cache console.log(memoisedSquare(9)); // New argument — must compute console.log(memoisedSquare(6)); // Back to 6 — still in cache console.log(memoisedSquare(9)); // 9 is now cached too
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.
// Production-grade memoisation for functions with multiple arguments. // Uses a null-byte separator for primitives and warns on object arguments. function memorise(targetFunction) { const resultCache = new Map(); return function memoisedFunction(...argumentList) { // Build a cache key from all arguments. // The null byte (\0) is used as a separator because it cannot appear // naturally in typical string arguments, minimising collision risk. const cacheKey = argumentList .map(arg => { if (arg !== null && typeof arg === 'object') { // Objects are serialised — note this won't handle circular refs. // For object-heavy use cases, prefer the trie approach instead. return JSON.stringify(arg); } return String(arg); }) .join('\0'); if (resultCache.has(cacheKey)) { return resultCache.get(cacheKey); } const result = targetFunction.apply(this, argumentList); resultCache.set(cacheKey, result); return result; }; } // A function that blends two paint colours with a mixing ratio. // Imagine this hits a real colour-science algorithm in production. function blendColours(colourA, colourB, ratio) { // Simplified stand-in for a complex colour blend computation return `blend(${colourA}, ${colourB}, ratio=${ratio})`; } const memoisedBlend = memorise(blendColours); // First calls — all cache misses, real work happens console.log(memoisedBlend('red', 'blue', 0.5)); console.log(memoisedBlend('red', 'blue', 0.7)); console.log(memoisedBlend('red', 'blue', 0.5)); // Cache hit — same three args // Demonstrates why argument ORDER matters for the key console.log(memoisedBlend('blue', 'red', 0.5)); // Miss — different order // Demonstrates the null-byte separator preventing collisions: // Without it, ('a|b', 'c') and ('a', 'b|c') would collide. const memoisedConcat = memorise((a, b) => `${a}+${b}`); console.log(memoisedConcat('a|b', 'c')); // key: 'a|b\0c' console.log(memoisedConcat('a', 'b|c')); // key: 'a\0b|c' — different, correct!
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.
// Demonstrates the self-reference trap in recursive memoisation // and shows both the broken and the correct pattern side by side. // --- BROKEN PATTERN --- // The recursive calls inside go to the original, un-memoised function. function naiveFibonacci(n) { if (n <= 1) return n; return naiveFibonacci(n - 1) + naiveFibonacci(n - 2); // calls original! } function memorise(fn) { const cache = new Map(); return function memoised(...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; }; } // The outer call IS memoised, but sub-calls go to naiveFibonacci — no benefit. const brokenMemoisedFib = memorise(naiveFibonacci); // --- CORRECT PATTERN --- // We declare the variable first, then assign it — so the function body // can reference the memoised version of itself through the variable. let fibonacci; fibonacci = memorise(function(n) { if (n <= 1) return n; // 'fibonacci' here refers to the memoised wrapper, not the raw function. // This means every sub-problem call gets cached correctly. return fibonacci(n - 1) + fibonacci(n - 2); }); // Track how many real computations happen to prove memoisation is working let computationCount = 0; fibonacci = memorise(function(n) { computationCount++; if (n <= 1) return n; return fibonacci(n - 1) + fibonacci(n - 2); }); const result = fibonacci(10); console.log(`fibonacci(10) = ${result}`); console.log(`Total computations performed: ${computationCount}`); // Without memoisation this would compute 177 times for n=10. // With correct recursive memoisation it computes exactly 11 times (0 through 10). // Second call — should need ZERO new computations computationCount = 0; console.log(`fibonacci(10) again = ${fibonacci(10)}`); console.log(`Computations on second call: ${computationCount}`);
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.
// A memoisation utility with LRU eviction — safe for production use // where the input space is large or unbounded. function memoiseLRU(targetFunction, maxCacheSize = 100) { // We use a Map for the cache because Map preserves insertion order, // which makes implementing LRU eviction straightforward. const lruCache = new Map(); return function memoisedWithLRU(...argumentList) { const cacheKey = argumentList.map(String).join('\0'); if (lruCache.has(cacheKey)) { // LRU trick: delete and re-insert to move this entry to the end // (most recently used position) of the Map's iteration order. const cachedValue = lruCache.get(cacheKey); lruCache.delete(cacheKey); lruCache.set(cacheKey, cachedValue); return cachedValue; } const freshResult = targetFunction.apply(this, argumentList); // If we're at capacity, evict the least recently used entry. // Map.keys().next().value gives us the first (oldest) key in iteration order. if (lruCache.size >= maxCacheSize) { const oldestKey = lruCache.keys().next().value; lruCache.delete(oldestKey); console.log(`[LRU EVICT] Removed entry for key: "${oldestKey}"`); } lruCache.set(cacheKey, freshResult); return freshResult; }; } // Simulate an expensive prime-check — in reality this could be a // complex data transformation or a third-party library call. function isPrime(num) { if (num < 2) return false; for (let divisor = 2; divisor <= Math.sqrt(num); divisor++) { if (num % divisor === 0) return false; } return true; } // Tiny cache of 3 to demonstrate eviction clearly const memoisedIsPrime = memoiseLRU(isPrime, 3); console.log(memoisedIsPrime(7)); // Miss — cache: [7] console.log(memoisedIsPrime(11)); // Miss — cache: [7, 11] console.log(memoisedIsPrime(13)); // Miss — cache: [7, 11, 13] console.log(memoisedIsPrime(7)); // Hit — moves 7 to end: [11, 13, 7] console.log(memoisedIsPrime(17)); // Miss, cache full — evicts 11: [13, 7, 17] console.log(memoisedIsPrime(13)); // Hit — still in cache
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.
// Quick benchmark to decide if memoisation is worth it for your function. 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; }; } // Example: an expensive regex validation (simulated) function validateEmail(email) { // In reality this could be a complex regex or API call let result = 0; for (let i = 0; i < email.length; i++) { result += email.charCodeAt(i); } return result; } const memoisedValidate = memoise(validateEmail); function benchmark(label, fn, argsList) { const start = performance.now(); for (const args of argsList) { fn(...args); } const end = performance.now(); console.log(`${label}: ${(end - start).toFixed(2)}ms for ${argsList.length} calls`); } // Generate 100 unique emails const uniqueEmails = Array.from({ length: 100 }, (_, i) => `user${i}@example.com`); const singleEmail = ['test@example.com']; // Benchmark unique calls (all misses) benchmark('Un-memoised (unique)', validateEmail, uniqueEmails.map(e => [e])); benchmark('Memoised (unique)', memoisedValidate, uniqueEmails.map(e => [e])); // Benchmark repeated calls (all hits after first) benchmark('Un-memoised (repeated)', validateEmail, Array(100).fill(singleEmail)); benchmark('Memoised (repeated)', memoisedValidate, Array(100).fill(singleEmail)); // Sample output may vary by engine: // Un-memoised (unique): 0.15ms for 100 calls // Memoised (unique): 0.25ms for 100 calls (overhead) // Un-memoised (repeated): 0.12ms for 100 calls // Memoised (repeated): 0.02ms for 100 calls (6x faster)
- 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.Memoisation is Not Free — The Cache Trade-Off You’re Ignoring
Every cached result burns memory. Every key lookup costs CPU. Most tutorials pretend memoisation is a magic speed button. It’s not.
The real question isn’t “can I memoise this?” It’s “is the hit rate high enough to justify the overhead?” If your function gets called with unique arguments 90% of the time, you’re just building a Map that never gets read. Worse: you’re leaking memory.
Production rule of thumb: profile before you memoise. Use WeakRef if the cache lives longer than a single request. Never cache results that are cheaper to recompute than to serialize as a key.
Benchmark your cache lookup vs your pure function. If the function runs in under 50µs, your cache key serialisation alone probably costs more. Yes, even JSON.stringify.
// io.thecodeforge — javascript tutorial function memoise(fn) { const cache = new Map(); return function (...args) { const key = JSON.stringify(args); if (cache.has(key)) return cache.get(key); const result = fn(...args); cache.set(key, result); return result; }; } function expensive(a, b) { let sum = 0; for (let i = 0; i < 1e6; i++) sum += a + b; return sum; } const memoised = memoise(expensive); console.time('first'); memoised(5, 10); console.timeEnd('first'); console.time('cached'); memoised(5, 10); console.timeEnd('cached');
The Hidden Pitfall: Async Memoisation and Promise Identity
Memoising an async function looks straightforward — cache the Promise, return it on repeat calls. But you’re not caching the value, you’re caching a Promise reference. If that Promise rejects once, your cache now holds a rejected Promise forever. Every subsequent call with the same args gets that rejection. No retry. No recovery.
Solution: cache the result after resolution, not the Promise itself. Track pending requests with a separate pending map. When the Promise settles, delete the pending entry and store the resolved value — or the rejection reason, if you want error caching (usually you don’t).
This matters for API calls, DB queries, and any I/O where transient failures happen. Your cache shouldn’t turn a 503 into a permanent black hole.
// io.thecodeforge — javascript tutorial function memoiseAsync(fn) { const cache = new Map(); const pending = new Map(); return async function (...args) { const key = JSON.stringify(args); if (cache.has(key)) return cache.get(key); if (pending.has(key)) return pending.get(key); const promise = fn(...args).then( (result) => { cache.set(key, result); pending.delete(key); return result; }, (err) => { pending.delete(key); throw err; } ); pending.set(key, promise); return promise; }; } const fetchUser = memoiseAsync((id) => fetch(`/users/${id}`).then(r => r.json())); fetchUser(42).then(console.log); // fetches fetchUser(42).then(console.log); // reads cached value
Production-Grade Cache Invalidation: You Can’t Afford to Ignore TTLs
Memoisation without expiration is a memory bomb. Every tutorial glosses over this because it’s boring. Production disagrees.
In real apps, most caches need a TTL — Time To Live. Why? Because data changes. User profiles get updated, stock prices move, inventory counts shift. If you memoise a getUserProfile call without a TTL, you’re serving stale data until the process restarts. Or until OOM kills it.
Implement a simple sliding TTL: the cache entry expires after N milliseconds since last access. Or a fixed TTL: N milliseconds since creation. For most CRUD apps, fix it at 30-60 seconds. For financial data, fix it at 100ms. And always, always attach metadata: created timestamp, access count, hit count.
When the garbage collector runs your next build, your future self will thank you.
// io.thecodeforge — javascript tutorial function memoiseWithTTL(fn, ttlMs = 60000) { const cache = new Map(); return function (...args) { const key = JSON.stringify(args); const entry = cache.get(key); if (entry && (Date.now() - entry.created) < ttlMs) { return entry.result; } const result = fn(...args); cache.set(key, { result, created: Date.now() }); return result; }; } function fetchStockPrice(ticker) { // imagine an actual API call return Math.random() * 200 + 100; } const getPrice = memoiseWithTTL(fetchStockPrice, 5000); console.log(getPrice('AAPL')); setTimeout(() => console.log(getPrice('AAPL')), 3000); // still cached setTimeout(() => console.log(getPrice('AAPL')), 6000); // expired, re-fetched
Memoisation with `this`: Why Context Destroys Your Cache
Your carefully crafted memoisation breaks the moment this changes. That’s because JavaScript functions bind this at call time, not definition time. If your memoised function uses this, every call with a different receiver creates a different execution context. Your cache stores results keyed only by explicit arguments, not the implicit receiver.
Solution? Cache on a composite key that includes the receiver identity. Or better: don’t use this in memoised functions at all. Pass the relevant data as an explicit argument. If you must use class methods, bind the method to the instance once and memoise the bound version. Watch for instances where .call or .apply sneaks in — your cache just became a memory leak disguised as optimization.
// io.thecodeforge — javascript tutorial const memoize = (fn) => { const cache = new WeakMap(); return function(...args) { const key = this ?? globalThis; if (!cache.has(key)) cache.set(key, new Map()); const inner = cache.get(key); const argKey = JSON.stringify(args); if (inner.has(argKey)) return inner.get(argKey); const result = fn.apply(this, args); inner.set(argKey, result); return result; }; }; class Calculator { constructor(multiplier) { this.multiplier = multiplier; } compute(n) { return this.multiplier * n; } } const calc = new Calculator(2); const memoCompute = memoize(Calculator.prototype.compute); console.log(memoCompute.call(calc, 5)); // 10 console.log(memoCompute.call(calc, 5)); // 10 (cached) console.log(memoCompute.call({multiplier: 3}, 5)); // 15 (different this!)
this look memoiseable but aren't. Use WeakMap keyed on instance to avoid leaks — but ask yourself why you're mixing OOP with memoisation.this into the argument key or use WeakMap per instance.Memoise Conditional Results Without Poisoning Your Cache
What happens when your function returns different results for the same input based on an external state? You cache the first result, and subsequent calls get stale data. The classic fix is purging the cache when state changes, but that’s throwing the baby out with the bathwater.
Better approach: include the relevant state in your cache key. Time-based state? Add a timestamp chunk to the key — but be smart, chunk by hour, not millisecond. Feature flags? Include the flag version. Database state? Don’t memoise that at all — it’s a query cache problem, not a function cache problem. The rule: if the result depends on something invisible to the argument list, you’ve built a silent bug factory.
// io.thecodeforge — javascript tutorial const memoizeWithState = (fn, getStateKey) => { const cache = new Map(); return (...args) => { const stateKey = getStateKey(); const fullKey = `${stateKey}::${JSON.stringify(args)}`; if (cache.has(fullKey)) return cache.get(fullKey); const result = fn(...args); cache.set(fullKey, result); return result; }; }; let isPremium = false; const getDiscount = (price) => isPremium ? price * 0.8 : price * 0.95; const memoDiscount = memoizeWithState( getDiscount, () => `v1::${isPremium}` ); console.log(memoDiscount(100)); // 95 isPremium = true; console.log(memoDiscount(100)); // 80 (fresh because state changed)
What Is Memoisation?
Memoisation is an optimisation that stores the result of a function call and returns the cached result when the same input reoccurs. It transforms a pure, deterministic function into one that trades memory for speed. The core contract: same input always yields same output, so recomputation wastes cycles. This works only for pure functions — those with no side effects and no reliance on external state. If a function reads global variables, calls random, or mutates arguments, memoisation returns stale or wrong values. Impure functions break the cache contract silently. Before reaching for memoisation, verify your function is a pure, idempotent computation. Otherwise, you introduce subtle, hard-to-debug data corruption. Memoisation is not a generic speed-up; it’s a precision tool for pure, repeatable work.
// io.thecodeforge — javascript tutorial // Pure: memoisable const add = (a, b) => a + b; // Impure: cache breaks immediately let counter = 0; const impureAdd = (a, b) => { counter++; return a + b + counter; }; memoise(impureAdd)(1, 2); // First call -> 4 memoise(impureAdd)(1, 2); // Cached -> 4, but expected 5
Date.now() or Math.random() silently returns the same value forever — your app becomes a ticking time bomb.Caveats and Pre-requisites
Memoisation fails when your function uses this, relies on closures with mutable state, or expects reference equality for objects. The cache key is a serialised string of arguments — objects like {x:1} and {x:1} from different locations won’t match unless you implement a custom hasher. Recursive memoisation demands the cached version calls itself, not the original function — otherwise, inner calls bypass the cache. Pre-requisite: deep understanding of closures, referential transparency, and garbage collection. Without these, you’ll leak memory or compute wrong results. Never memoise constructors, generators, or functions that throw conditionally — exceptions poison the cache permanently. Always benchmark before and after: the cache lookup overhead can exceed recomputation cost for cheap operations.
// io.thecodeforge — javascript tutorial const memoise = (fn) => { const cache = new Map(); return (...args) => { const key = JSON.stringify(args); if (!cache.has(key)) cache.set(key, fn(...args)); return cache.get(key); }; }; const area = (r) => Math.PI * r * r; const memoArea = memoise(area); console.log(memoArea(5)); // 78.54 console.log(memoArea('5')); // "5" !== 5, new cache miss, wrong result!
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).console.log('key:', cacheKey, 'args:', args); // at the start of memoised functionUse a Map with .size to log cache size: console.log('cache size:', resultCache.size);JSON.stringify outside the functionnode --inspect index.js → open chrome://inspect → take snapshot → filter by '\0' or 'join'Add a setInterval to log cache size every 30 seconds in developmentconsole.log('Calling fibonacci — is this the memoised version?', arguments.callee);Temporarily replace the function in dev with a counter: let calls = 0; inside the functionlet fib = memoise(function f(n) { return fib(n-1) + fib(n-2); });console.log('external value at call time:', externalValue); // to confirm it's unchangedTemporarily disable memoisation and compare results side by side| Aspect | Memoisation (top-down) | Tabulation (bottom-up) |
|---|---|---|
| Approach | Recursive with a result cache | Iterative, fills table from base case up |
| Sub-problems computed | Only the ones actually needed | All sub-problems, even unused ones |
| Call stack risk | Yes — deep recursion can overflow | None — fully iterative |
| Code readability | Mirrors the mathematical definition closely | More explicit, less immediately intuitive |
| Memory usage | Cache grows with unique inputs seen | Fixed table size known upfront |
| Best for | Sparse problem spaces (not all sub-problems needed) | Dense problem spaces (most sub-problems needed) |
| Debugging | Harder — recursive call chains obscure flow | Easier — step through the table directly |
| React equivalent | useMemo / useCallback (scope: single render) | No direct equivalent — manual state management |
| File | Command / Code | Purpose |
|---|---|---|
| basicMemorise.js | function memorise(expensiveFunction) { | How Memoisation Works Under the Hood |
| multiArgMemorise.js | function memorise(targetFunction) { | Handling Multiple Arguments |
| recursiveMemoFibonacci.js | function naiveFibonacci(n) { | Recursive Memoisation |
| lruMemorise.js | function memoiseLRU(targetFunction, maxCacheSize = 100) { | Cache Invalidation, Memory Leaks, and Production Patterns |
| benchmarkMemoisation.js | function memoise(fn) { | Performance Benchmarks |
| CacheOverheadCheck.js | function memoise(fn) { | Memoisation is Not Free |
| AsyncMemoise.js | function memoiseAsync(fn) { | The Hidden Pitfall |
| MemoiseWithTTL.js | function memoiseWithTTL(fn, ttlMs = 60000) { | Production-Grade Cache Invalidation |
| context-leak.js | const memoize = (fn) => { | Memoisation with `this` |
| conditional-memo.js | const memoizeWithState = (fn, getStateKey) => { | Memoise Conditional Results Without Poisoning Your Cache |
| PureVsImpure.js | const add = (a, b) => a + b; | What Is Memoisation? |
| CaveatSplitArgs.js | const memoise = (fn) => { | Caveats and Pre-requisites |
Key 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`.Memoisation is sometimes described as a trade-off. What exactly are you trading, what are the conditions under which that trade-off is worthwhile, and what type of function is unsafe to memoise?
If you memoise a recursive function by wrapping it externally — like `const memFib = memoise(fibonacci)` — does the caching apply to the internal recursive calls? Why or why not, and how would you fix it if not?
fibonacci function, the recursive calls refer to the original unwrapped function by name, not to the wrapper. So every intermediate fibonacci(n-1) and fibonacci(n-2) misses the cache. To fix it, you need to reassign the function variable to the memoised version before the function body executes, so that internal calls go through the same memoised reference:
``javascript
let fibonacci;
fibonacci = memoise(function(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
});
`
Now fibonacci` inside the function refers to the memoised version, and all sub-problems are cached.How would you implement an LRU memoisation cache in JavaScript? Explain the data structure choices.
Map.keys().next().value. Here's a compact implementation:
``javascript
function memoiseLRU(fn, max = 100) {
const cache = new Map();
return function(...args) {
const key = args.join('\0');
if (cache.has(key)) {
const value = cache.get(key);
cache.delete(key);
cache.set(key, value);
return value;
}
const result = fn.apply(this, args);
if (cache.size >= max) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
cache.set(key, result);
return result;
};
}
``
This approach is simple, O(1) for get/set, and doesn't require a separate linked list implementation. For very large caches (>10k entries), the delete-then-re-set pattern may be slower than a dedicated linked-list + Map combo, but for most production scenarios this works well.Frequently Asked Questions
Caching is the general concept of storing computed results for reuse. Memoisation is a specific form of caching that is tied directly to a function — it caches the return value of a pure function keyed by its input arguments, and the cache is scoped to that function's closure. General caching might involve HTTP responses, database queries, or arbitrary data stores with manual invalidation logic.
Not quite. React's useMemo is component-scoped and has a cache size of exactly one: it only remembers the result from the most recent render, and only reuses it if the dependency array hasn't changed. A general memoisation utility grows its cache across every unique set of arguments it has ever seen. useMemo is optimised for referential stability between renders, not for avoiding repeated expensive computations across different inputs.
No. Memoisation adds overhead: it must compute the cache key, perform a Map lookup, and potentially serialise arguments on every single call — even cache misses. For functions that are already very fast (simple arithmetic, single property access), this overhead can actually make them slower than the un-memoised version. Memoisation pays off when the function being wrapped is significantly more expensive than the cache lookup, and when the same arguments are genuinely repeated across calls.
Expose the cache or provide a method. For the implementation shown in this article, you can modify the wrapper to return an object with both the memoised function and a clear method: clear()return { fn: memoisedFunction, clear: () => . Alternatively, reassign the function variable to a fresh memoised version.cache.clear() }
Yes, but be careful. If the async function is called multiple times with the same arguments before the first call resolves, you'll launch multiple concurrent computations unless you also cache the promise itself. A common pattern is to cache the returned promise, so subsequent calls await the same promise. Also remember that an async function that calls external APIs is impure — the API response may change between calls, so add a TTL to the cache.
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
That's Advanced JS. Mark it forged?
9 min read · try the examples if you haven't