Back to blog
Apr 2026·3 min readAI-generated

Memoization in JavaScript, Beyond useMemo

#javascript#performance

Memoization caches the result of a function call keyed by its arguments, so calling it again with the same arguments returns the cached value instead of redoing the work. Most JavaScript developers meet it through React's useMemo, but that's one narrow application of a general technique that's just as useful in plain functions, recursive algorithms, and server-side code that has nothing to do with rendering.

The core pattern

Memoization needs three things: a way to derive a cache key from the arguments, a store to hold cached results, and a wrapper that checks the store before calling the real function.

function memoize(fn, keyFn = (...args) => JSON.stringify(args)) {
  const cache = new Map();

  return function memoized(...args) {
    const key = keyFn(...args);
    if (cache.has(key)) {
      return cache.get(key);
    }
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}
function expensiveCalculation(n) {
  console.log(`Computing for ${n}...`);
  let total = 0;
  for (let i = 0; i < n * 1e7; i++) total += i;
  return total;
}

const memoized = memoize(expensiveCalculation);

memoized(5); // Computing for 5... (slow)
memoized(5); // instant, no log line — served from cache
memoized(6); // Computing for 6... (slow, different key)

The default JSON.stringify key function works for primitive and simple object arguments, but breaks down for functions, Map/Set arguments, or objects where key order isn't guaranteed to be stable — pass a custom keyFn when the default won't do.

Why this matters beyond React

useMemo exists to avoid recomputing a value on every render of a component. That's a real use case, but memoization is equally valuable anywhere a function is:

  • Pure (same input always produces the same output), and
  • Expensive relative to a cache lookup (a database query, a regex against a huge string, a recursive computation).

None of that requires React. A Node.js API handler that recomputes the same aggregate report for every request in a burst, a CLI tool that re-parses the same config file per invocation within a batch, or a recursive algorithm that recomputes the same subproblem repeatedly are all good candidates.

The classic case: recursive Fibonacci

Naive recursive Fibonacci is the textbook example because the performance difference is dramatic and easy to see.

function fib(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
}

fib(35) makes roughly 30 million calls, most of them recomputing the same values — fib(10) gets calculated thousands of times as a subproblem of fib(35). Memoizing collapses that to one call per distinct n:

function memoizedFib() {
  const cache = new Map([[0, 0], [1, 1]]);

  function fib(n) {
    if (cache.has(n)) return cache.get(n);
    const result = fib(n - 1) + fib(n - 2);
    cache.set(n, result);
    return result;
  }

  return fib;
}

const fib = memoizedFib();
fib(35); // 30 million calls collapse to 36

This is memoization applied to recursion, sometimes called "top-down dynamic programming" — same idea as the generic memoize() wrapper, just applied by hand inside the recursive function so each call checks the cache before recursing further.

Cache growth is the tradeoff, not a footnote

An unbounded Map cache is a memory leak waiting to happen if the function is called with many distinct arguments over the app's lifetime — a memoized per-request handler in a long-running server is a classic way to slowly grow unbounded memory. Bound the cache with an LRU (least-recently-used) eviction policy once that's a real risk:

function memoizeWithLimit(fn, maxSize = 100) {
  const cache = new Map();

  return function memoized(...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) {
      const value = cache.get(key);
      cache.delete(key);
      cache.set(key, value); // move to "most recently used" position
      return value;
    }

    const result = fn.apply(this, args);
    cache.set(key, result);

    if (cache.size > maxSize) {
      const oldestKey = cache.keys().next().value; // Map preserves insertion order
      cache.delete(oldestKey);
    }

    return result;
  };
}

Map iterates in insertion order, and re-inserting a key on access (delete then set) moves it to the end — that's enough to implement LRU eviction without a separate linked list.

Why useMemo isn't "memoization, but for React"

useMemo(() => computeThing(a, b), [a, b]) looks like the generic pattern, but it has React-specific semantics worth knowing:

  • The cache holds exactly one entry — the last dependency array. It's not a Map keyed by every distinct input ever seen; a new dependency array evicts the previous result entirely.
  • React is explicitly allowed to discard the memoized value and recompute it anyway (for memory pressure reasons), so useMemo is a performance hint, not a correctness guarantee — never rely on it for side effects or to avoid re-running something with observable effects.
  • It only exists for the lifetime of the component instance.

A generic memoize() with a Map cache has none of those constraints and none of those safety rails — which is exactly why it's the wrong tool inside a component (unbounded growth across renders) and the right tool for a standalone pure function.

When not to memoize

Memoization trades memory for CPU time. It's a poor fit when the function is cheap to begin with (the cache lookup can cost more than just recomputing), when arguments are rarely repeated (a cache that's never hit is pure overhead), or when the function isn't actually pure (memoizing something that depends on mutable external state will happily return stale, wrong answers). Profile before reaching for it — memoization fixes a measured hotspot, it isn't a default you sprinkle over every function.

NewerAsync Iterators and Generators in JavaScriptOlderBuilding a Simple Pub/Sub System in Node.js