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 Maplodash.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 WeakMap on 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.
What each memoization strategy retains Unbounded memoize retains one entry for every distinct argument ever seen and grows forever. Memoize-one retains only the last arguments and result. A WeakMap memo retains one entry per live argument object, released when the object is collected. A bounded LRU memo retains at most N entries, evicting the least recently used. memoize (generic) every distinct argument, forever grows with input variety only for tiny fixed domains memoize-one last args + result always 1 entry state-derived values WeakMap memo one per live argument object freed with the object derived data per object LRU memo at most N entries capped pure fn over IDs

Step-by-Step Fix

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. Re-measure the long session. Repeat step 2. Verification: memo caches are constant-size, object-tied or capped, and hit rates remain acceptable.
Memo cache over a six-hour session A price formatter memoised with an unbounded cache keyed by JSON.stringify of its arguments grows to about 300,000 entries and 140 megabytes over six hours. The same function memoised with a 2,000-entry LRU keyed by product ID and currency stays at 2,000 entries and about 1 megabyte, with a similar hit rate. 140 MB 0 memoize keyed by JSON.stringify(args) LRU memo (2,000) keyed by id + currency session hours 0 → 6

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.

Expected cache size for each memo strategy After the same soak test that exposed the growth, each memo should match its strategy: memoize-one holds one entry, a WeakMap-keyed memo shrinks when argument objects are released, and an LRU memo stays at its max. A correctly scoped memo that never hits can simply be removed. Memo strategy used Exactly one entry at any time memoize-one Shrinks when argument objects are released WeakMap by argument object Stays at max; check the hit rate justifies it LRU with max

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.