Memoization in JavaScript
Memoization stores function results in a cache keyed by input arguments, returning the cached value on repeat calls to skip recomputation.
Theory
TL;DR
- Analogy: a chef who writes down exact recipes after cooking a dish once. Same order next time, pull from notes.
- Requirement: the function must be pure. Same input always produces the same output.
- Core trade-off: memory for speed. A growing cache with no limit is a memory leak.
- Use when computation takes >10ms and inputs repeat in more than ~5% of calls.
- Skip for: simple arithmetic, functions with side effects, rarely-repeated inputs.
Quick Example
function memoize(fn) {
const cache = {};
return (...args) => {
const key = JSON.stringify(args);
if (key in cache) return cache[key]; // Cache hit: no work done
return (cache[key] = fn(...args)); // Cache miss: compute and store
};
}
const slowDouble = (n) => { /* expensive work */ return n * 2; };
const fastDouble = memoize(slowDouble);
fastDouble(5); // Computed: 10
fastDouble(5); // From cache: 10, zero computation
fastDouble(9); // New key, computed: 18The returned function shares one cache object via closure. On a cache hit it returns in O(1). On a miss it runs the original function and stores the result.
How It Works Inside
When memoize(fn) runs, it creates a closure. The returned function captures cache from the outer scope and keeps it alive for the lifetime of the memoized function, without touching the global scope.
JSON.stringify(args) turns the argument list into a stable string key. [5, 10] becomes "[5,10]". V8 optimizes repeated property lookups on objects with stable shapes (hidden classes), so cache hits are effectively O(1) in practice.
One real limitation: JSON.stringify loses type information for Date, NaN, Symbol, and undefined. NaN serializes to null, Symbol disappears entirely. Two different inputs can produce the same key. For those types, use a custom serializer or switch to a Map with tagged keys.
When to Use
- Recursive functions with overlapping subproblems (Fibonacci, tree traversal): avoids exponential recomputation.
- Pure functions called repeatedly with the same inputs (formatters, data transformers): skips identical work.
- API response transformers where raw data does not change between calls: cache by request params.
- Skip for: anything reading
Date.now(), random generators, functions with I/O side effects, simple arithmetic.
Cache Variants
A plain object cache grows forever. For long-running processes (Node.js servers, SPAs), that is a real problem. Two practical patterns fix it.
LRU (evicts least recently used):
function memoizeLRU(fn, maxSize = 100) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
const value = cache.get(key);
cache.delete(key); // Remove from current position
cache.set(key, value); // Re-insert at end (most recent)
return value;
}
const result = fn(...args);
cache.set(key, result);
if (cache.size > maxSize) {
cache.delete(cache.keys().next().value); // Drop oldest entry
}
return result;
};
}TTL (entries expire after a time limit):
function memoizeWithTTL(fn, ttl = 5000) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
const cached = cache.get(key);
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.value;
}
const result = fn(...args);
cache.set(key, { value: result, timestamp: Date.now() });
return result;
};
}TTL is the right tool when data can go stale, like caching an API response for 10 seconds.
Memoization in React
React ships three built-in memoization tools. They solve different problems.
React.memo wraps a component and skips re-rendering if props are shallowly equal to the previous render:
import { memo } from 'react';
const ProductList = memo(({ products, onSelect }) => {
return (
<ul>
{products.map(p => (
<li key={p.id} onClick={() => onSelect(p)}>{p.name}</li>
))}
</ul>
);
});
// Re-renders only when products or onSelect reference changesuseMemo caches a computed value inside a component. It reruns only when dependencies change:
import { useMemo } from 'react';
function Dashboard({ orders, activeStatus }) {
const filtered = useMemo(
() => orders.filter(o => o.status === activeStatus),
[orders, activeStatus]
);
return <OrderTable data={filtered} />;
}useCallback caches the function reference itself. This matters when passing a function as a prop to a memoized child, because a new function object on every render breaks the child's memo:
import { useCallback, memo, useState } from 'react';
const Button = memo(({ onClick, label }) => (
<button onClick={onClick}>{label}</button>
));
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount(prev => prev + 1);
}, []); // Same reference across renders
return <Button onClick={handleClick} label={`Count: ${count}`} />;
}The mistake I see most: wrapping useMemo around count * 2. That is nanosecond arithmetic. The hook overhead is larger than the savings.
Common Mistakes
Memoizing impure functions:
const getTime = memoize(() => Date.now());
getTime(); // Caches the first timestamp
getTime(); // Returns stale cached value - wrongMemoization only makes sense when the same arguments always produce the same result.
Mutable arguments poisoning the cache:
function badMemo(fn) {
const cache = {};
return (arg) => {
cache[arg.id] = fn(arg);
arg.processed = true; // Mutates the input after caching
return cache[arg.id];
};
}
// Subsequent call sees the mutated arg, returns a stale cached resultFix: use a stable primitive as the key (arg.id), not the mutable object itself.
No cache size limit in a long-lived process:
const cache = {}; // Grows without limit in a Node.js server
// After sustained traffic: 1GB+ heap, OOM crashFix: use the lru-cache npm package or the LRU implementation above.
JSON.stringify on Dates or Symbols:
JSON.stringify([new Date()]); // Becomes a string, loses Date type
JSON.stringify([Symbol('id')]); // '[null]' - Symbol is gone
// Different inputs, same cache key: wrong resultsFix: write a custom serializer or use type-tagged string keys.
Real-World Usage
- React 18:
useMemofor filtering, sorting, or aggregating large lists inside components. - Redux Toolkit / Reselect:
createSelectormemoizes derived state slices in large apps. - Lodash 4.17:
_.memoizewith a custom resolver, used in Webpack loaders for parse caching. - Next.js 14:
unstable_cachecaches RSC data fetches so the same query does not re-run per request. - Express / Node.js:
apicachemiddleware caches GET responses by URL and query params.
Follow-up Questions
Q: What is the difference between memoization and dynamic programming?
A: Memoization is top-down: it computes lazily and caches results on the way down. DP is bottom-up: it fills a table from the smallest subproblems eagerly, before you ask for the result.
Q: What happens to memory with 1 million unique cache keys?
A: Roughly 100MB for simple key-value pairs. Cap the cache with LRU or use WeakMap so the GC can collect entries when argument objects are no longer referenced elsewhere.
Q: Why does JSON.stringify fail on NaN and Symbol?
A: NaN becomes null and Symbol is stripped. Two different inputs can map to the same string key, causing wrong cache hits. Use a custom serializer that includes type tags.
Q: In React, what is the difference between React.memo and useMemo?
A: React.memo is a higher-order component that skips re-rendering based on props. useMemo is a hook that caches a computed value inside a component based on a dependency array. Different scope, different use case.
Q (senior): How would you share a memoization cache across multiple Node.js worker processes?
A: Use Redis with a TTL per key via ioredis. Each process reads from and writes to the shared store. Add a pub/sub channel or a versioned key for cache invalidation when the underlying data changes.
Examples
Fibonacci: Without and With Memoization
// Without memo: O(2^n) time
// fib(3) runs 2 times, fib(2) runs 3 times for fib(5)
function fib(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
console.time('fib no memo');
fib(40); // ~1500ms
console.timeEnd('fib no memo');
// With memo: O(n) time
// Each subproblem computed exactly once
function memoize(fn) {
const cache = {};
return (...args) => {
const key = JSON.stringify(args);
if (key in cache) return cache[key];
return (cache[key] = fn(...args));
};
}
const fibMemo = memoize((n) => {
if (n <= 1) return n;
return fibMemo(n - 1) + fibMemo(n - 2);
});
console.time('fib memo');
fibMemo(40); // ~0.5ms
console.timeEnd('fib memo');The memoized version drops from exponential to linear time because each value is computed once and stored. fibMemo(3) runs exactly one time regardless of how many larger calls depend on it.
React useMemo for Expensive Filtering
import { useMemo, useState } from 'react';
function OrderDashboard({ orders }) {
const [activeStatus, setActiveStatus] = useState('pending');
// Without useMemo: filters 10k orders on every render
// With useMemo: refilters only when orders or activeStatus change
const filtered = useMemo(
() => orders.filter(o => o.status === activeStatus),
[orders, activeStatus]
);
return (
<>
<StatusTabs value={activeStatus} onChange={setActiveStatus} />
<OrderTable data={filtered} />
</>
);
}On a dataset of 10,000 orders, filtering on each render adds measurable delay. useMemo makes it run only when something actually changes.
TTL Cache for Repeated API Calls
function memoizeWithTTL(fn, ttl = 5000) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
const cached = cache.get(key);
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.value; // Fresh enough, return cached
}
const result = fn(...args); // Expired or missing, recompute
cache.set(key, { value: result, timestamp: Date.now() });
return result;
};
}
const getUser = memoizeWithTTL(async (id) => {
const res = await fetch(`/api/users/${id}`);
return res.json();
}, 10_000); // Cache responses for 10 seconds
await getUser(42); // Fetches from API
await getUser(42); // Returns cached result (within 10s)
// After 10 seconds: fetches fresh data automaticallyThis pattern works well for data that changes occasionally but gets requested often. The TTL prevents stale data from living too long.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.