Memoization Without Unbounded Memory Growth
A memoised formatter, selector or pricing function made the page faster — and six hours later it holds 300,000 cached results, one for every argument combination it ever saw. This guide from Memory-Safe Caching Patterns in JavaScript, part of JavaScript Memory Fundamentals & Runtime Mechanics, explains why generic memoization leaks, and which of three bounded strategies — last-call, weak-keyed or LRU — fits each kind of function.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Memoised function’s cache grows all session | Generic memoize stores every distinct key forever | Replace with memoize-one, WeakMap or bounded LRU memo | Cache size bounded or tied to argument lifetime |
Memo keyed by JSON.stringify(args) explodes |
Every distinct argument object creates a new long string key | Key by identity (WeakMap) or by a small stable ID | Fewer, smaller keys |
| Memo keeps old state objects alive | Cache keys or values reference previous app state | Use last-call memoization for state-derived values | Only the latest state retained |
| Selector cache retains unmounted components’ data | Per-instance selectors stored globally | Create selectors per instance and drop them on unmount | Retention follows component lifetime |
| Memo never hits | Arguments are new objects every call | Memoize on stable inputs, not freshly built objects | Hit rate recovers; no cache growth |
Root Cause: Memoization Is a Cache With No Policy
Memoization stores a function’s results keyed by its arguments so repeated calls return instantly. Generic helpers implement that with an unbounded Map — lodash.memoize, for example, keys by the first argument and never evicts unless you replace its cache property. That is fine for functions called with a small, fixed set of arguments (a handful of locales, a few configuration objects). It becomes a leak for functions called with an open-ended set: user IDs, timestamps, search queries, row objects, or the ever-changing state objects of a UI. The cache then grows with the number of distinct arguments ever seen, exactly like the unbounded module caches in module-level caches and global singleton leaks.
Key construction makes it worse. Memoizing multi-argument functions often uses JSON.stringify(args) as the key, which allocates a new string per call, retains a long key per entry, and misses whenever argument objects differ only in key order. Keying by object identity in a Map retains every argument object — including large state trees — for as long as the memo lives.
There are three memory-safe shapes, each matching a common use:
- Last-call memoization (“memoize-one”): remember only the most recent arguments and result. Ideal for values derived from current state — selectors, computed props, render helpers — where the same inputs repeat until state changes. Memory: one entry, always.
- Weak-keyed memoization: key the cache with a
WeakMapon an object argument, so each entry lives exactly as long as the argument object. Ideal for derived data attached to objects — layouts per node, parsed views per document. Memory: tied to the argument’s own lifetime, as described in using WeakSet to tag objects without leaks. - Bounded LRU memoization: key by a small primitive (ID, normalised string) in an LRU with a maximum size. Ideal for pure functions over an open-ended but skewed set of inputs, such as formatting or pricing by ID. Memory: capped.
Step-by-Step Fix
- List memoised functions. Search for
memoize(,memoizeOne(,createSelector(, and hand-written caches inside function wrappers. Verification: you have each memoised function and its argument types. - Measure their caches. In a long-session heap snapshot, find each memo’s cache object and read its retained size; or temporarily log cache sizes. Verification: you know which memos grow with session length.
- Classify each function. State-derived (same inputs repeat until state changes) → memoize-one. Object-derived (result belongs to an object) → WeakMap. Pure over open-ended primitives → bounded LRU. Verification: each function has a chosen strategy.
- Fix the keys. Replace
JSON.stringify(args)keys with identity (WeakMap) or with small, normalised primitives such as IDs. Verification: no memo builds long string keys per call. - Scope instance-specific memos. Create selectors or memoised helpers per component instance and let them die with it, rather than keeping one global memo keyed by instance. Verification: unmounting components releases their memo entries.
- Re-measure the long session. Repeat step 2. Verification: memo caches are constant-size, object-tied or capped, and hit rates remain acceptable.
Command and Code Reference
Use case: last-call memoization for state-derived values.
// memoizeOne: remembers only the latest arguments and result
export function memoizeOne(fn) {
let lastArgs = null;
let lastResult;
return function (...args) {
if (lastArgs && args.length === lastArgs.length && args.every((a, i) => Object.is(a, lastArgs[i]))) {
return lastResult; // same inputs as last time
}
lastArgs = args; // previous args become collectable
lastResult = fn.apply(this, args);
return lastResult;
};
}
const visibleTodos = memoizeOne((todos, filter) => todos.filter((t) => filter(t)));
Use case: WeakMap memoization for data derived from objects.
export function memoizeByObject(fn) {
const cache = new WeakMap(); // entry lives as long as the key object
return function (obj) {
if (cache.has(obj)) return cache.get(obj);
const result = fn(obj);
cache.set(obj, result);
return result;
};
}
const wordCount = memoizeByObject((doc) => doc.text.split(/\s+/).length);
Use case: bounded LRU memoization over primitive keys.
import { BoundedLru } from './bounded-lru.js';
export function memoizeLru(fn, { max = 1000, key = (...a) => a.join('|') } = {}) {
const cache = new BoundedLru({ max });
return function (...args) {
const k = key(...args); // small, normalised primitive key
const hit = cache.get(k);
if (hit !== undefined) return hit;
const result = fn(...args);
cache.set(k, result);
return result;
};
}
const formatPrice = memoizeLru(
(productId, currency) => expensiveFormat(productId, currency),
{ max: 2000, key: (id, cur) => `${id}:${cur}` },
);
Verification and Regression Prevention
Verify with the same long session or soak test that exposed the growth: each memo’s cache should be one entry (memoize-one), should shrink when argument objects are released (WeakMap), or should stay at its max (LRU). Check hit rates too; a correctly scoped memo that never hits is overhead without benefit and can simply be removed.
Prevent regressions with a lint rule that bans unbounded generic memoizers in application code (or requires a comment justifying a fixed, small domain), and provide the three helpers above as the sanctioned options. For framework-specific memoization — useMemo, computed, selectors — the lifetime is usually tied to components; see useMemo and useCallback memory cost for the React side.
Edge Cases and Gotchas
lodash.memoize keys only by the first argument
By default it uses the first argument as the key, so memoize(fn)(a, b) ignores b — a correctness bug as well as a memory one. Provide a resolver, or better, use a strategy suited to the function.
reselect versions differ
Older selector libraries cached only the last result per selector; newer versions may use weak-keyed caches with different retention. Check the version’s documented memoization behaviour and configure the cache size explicitly where it matters.
Memoizing async functions
Memoizing a function that returns a promise caches the promise, including rejections. Delete failed entries so errors are retried, and bound the cache as for any other value.
Closures captured by memoised results
If memoised results are functions (for example bound event handlers), each captures its arguments. The memo then retains those arguments via the closures, even with a WeakMap on a different key. Keep captured data minimal.
Frequently Asked Questions
Does memoization cause memory leaks?
It can. Generic memoizers store every distinct argument’s result forever, so functions called with open-ended inputs build caches that grow for the whole session or process. Bounding the cache, tying it to argument lifetimes with WeakMap, or remembering only the last call prevents that.
What is memoize-one?
A memoization strategy that remembers only the most recent arguments and result. It is ideal for values derived from current state, where inputs repeat until state changes, and it never holds more than one entry.
Why is JSON.stringify a bad memo key?
It allocates a new string on every call, retains long keys, depends on property order, and cannot represent functions, undefined or cyclic structures. Use identity for objects or a small, deliberate key built from the fields that matter.
How do I choose between memoize-one and an LRU memo?
Look at how arguments repeat. If the same arguments recur consecutively and then change for good — typical of values derived from the current UI state — memoize-one captures nearly all the benefit with a single entry. If many different arguments interleave and each recurs later — prices for products scattered across a page — an LRU sized to the working set is needed.
When is an unbounded memo acceptable?
When the domain of inputs is small and fixed — a few locales, a handful of feature flags, enum values. Then the cache is effectively bounded by the domain. Document that assumption so later changes that widen the domain are noticed.
Related
- Memory-Safe Caching Patterns in JavaScript — the parent topic
- TTL vs LRU Eviction: Memory Trade-offs — when memoised results can go stale
- Redux Store Memory Growth and Normalized State — selector memoization in state libraries
- JavaScript Memory Fundamentals & Runtime Mechanics — the section overview