DevLift
Back to Blog

Build Your Own Memoize

Call fib(45) naively and JavaScript will make over a billion recursive calls. Call it twice and you double that. Your CPU fan kicks on, the browser tab freezes, and the function you thought was fast becomes your performance bottleneck.

Admin
July 31, 20266 min read2 views

Build Your Own Memoize

Call fib(45) naively and JavaScript will make over a billion recursive calls. Call it twice and you double that. Your CPU fan kicks on, the browser tab freezes, and the function you thought was fast becomes your performance bottleneck. All because nobody remembered the answer to fib(10) the first time it was computed.

Memoize is one of those utilities you should build once and understand deeply rather than reaching for Lodash on autopilot. The core is about 20 lines. But those 20 lines hide a handful of interesting decisions: how you generate cache keys, what happens when your cache grows without bound, how to handle object arguments safely, and — the one that usually gets skipped — why naive memoization of recursive functions doesn't actually work, and how to fix it.

We'll build all four variants in plain JavaScript, understand where each one breaks down, and end up with a flexible implementation you can use in production.

What We're Building

  • memoize(fn) — single-argument caching with a Map
  • Multi-argument support with configurable key serialization
  • memoize(fn, { maxSize: N }) — LRU-bounded cache that won't eat memory forever
  • WeakMap-based caching for object arguments (GC-safe)
  • Correct memoization of recursive functions
Rendering diagram...

Step 1: The Core — Map-Based Single Argument

1

Map-based memoize for single arguments

// memoize.js
 
function memoize(fn) {
  const cache = new Map();
 
  return function memoized(arg) {
    if (cache.has(arg)) {
      return cache.get(arg);
    }
    const result = fn.call(this, arg);
    cache.set(arg, result);
    return result;
  };
}

Map keys work by value equality for primitives and by reference for objects. That's the right behavior for single numeric or string arguments: two calls with 42 hit the same cache entry.

const double = memoize((n) => {
  console.log('computing...');
  return n * 2;
});
 
double(5);  // computing... → 10
double(5);  // (no log) → 10 — cache hit
double(6);  // computing... → 12
double(5);  // (no log) → 10 — still cached

The closure over cache is the whole trick. Each call to memoize creates a new cache scoped to that memoized function. Two calls to memoize(double) produce two independent caches.

One thing fn.call(this, arg) preserves: the calling context. If you memoize a method and call it as obj.memoizedMethod(), this inside the original function still points to obj. Useful if the function does this.someProperty anywhere.

Step 2: Multiple Arguments

Single-arg works. The moment you have two arguments, you need a way to turn them into a single cache key.

2

Multi-argument memoize with key serialization

function memoize(fn, { keyFn } = {}) {
  const cache = new Map();
 
  // JSON.stringify covers most cases for primitive args
  const getKey = keyFn ?? ((...args) => JSON.stringify(args));
 
  return function memoized(...args) {
    const key = getKey(...args);
 
    if (cache.has(key)) {
      return cache.get(key);
    }
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

JSON.stringify(args) wraps the argument list in an array and serializes it: add(1, 2) gets key "[1,2]", add(2, 1) gets "[2,1]". Different inputs, different keys, as expected.

const add = memoize((a, b) => a + b);
 
add(1, 2);   // 3
add(1, 2);   // 3 (cache hit)
add(2, 1);   // 3 (cache miss — different key, correctly)

The keyFn escape hatch matters more than it looks. Not every function wants the default key strategy:

// Only care about the entity's ID, not the whole object
const getDisplay = memoize(formatUser, {
  keyFn: (user) => user.id
});
 
// Treat the argument set as order-independent
const findCommon = memoize(commonElements, {
  keyFn: (a, b) => [a, b].sort().join('::')
});
⚠️

JSON.stringify has quiet failure modes. It drops keys with undefined values, serializes NaN as null, makes Date objects into strings (losing their Date identity), and throws on circular references. For primitive arguments it's fine. For object arguments, use a custom keyFn or the WeakMap approach in Step 4.

Step 3: Bounded Cache with LRU Eviction

An unbounded cache is a memory leak that looks like an optimisation. Memoize a function called with 50,000 distinct argument combinations and you've got 50,000 entries sitting in memory until the page closes.

LRU (Least Recently Used) eviction keeps the cache bounded: when you hit the limit, throw out the entry that was accessed least recently. The insight is that Map preserves insertion order, and you can use that to track recency without a linked list.

3

LRU cache with configurable max size

class LRUCache {
  constructor(maxSize) {
    this.maxSize = maxSize;
    this.cache = new Map();
  }
 
  has(key) {
    return this.cache.has(key);
  }
 
  get(key) {
    if (!this.cache.has(key)) return undefined;
    // Re-insert to move to end = mark as recently used
    const value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value);
    return value;
  }
 
  set(key, value) {
    if (this.cache.has(key)) {
      this.cache.delete(key);
    } else if (this.cache.size >= this.maxSize) {
      // First key = least recently used — evict it
      const lruKey = this.cache.keys().next().value;
      this.cache.delete(lruKey);
    }
    this.cache.set(key, value);
  }
}
 
function memoize(fn, { keyFn, maxSize } = {}) {
  const cache = maxSize ? new LRUCache(maxSize) : new Map();
  const getKey = keyFn ?? ((...args) => JSON.stringify(args));
 
  return function memoized(...args) {
    const key = getKey(...args);
 
    if (cache.has(key)) {
      return cache.get(key);
    }
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

Every get does a delete-and-reinsert to push the accessed entry to the end of the Map. Every set of a new key first checks if we're at capacity and evicts the head (oldest entry) if so. No separate data structure needed — Map's ordering does all the work.

// Server-side: cache the last 500 user objects
const getUser = memoize(fetchFromDB, { maxSize: 500 });
 
// After 501 unique users are fetched,
// the least recently accessed one drops out automatically.
// Memory stays bounded regardless of how many users exist.

maxSize: 1 is a useful special case sometimes called "memoize-one". It only remembers the last call. If you call the function with the same arguments as last time, you get the cached result. Different arguments recompute and replace the cache. React's useMemo is the same idea applied to a dependency array — compare this render's deps to the previous render's, recompute only on change — though it isn't a keyed cache, and React reserves the right to throw the cached value away.

Step 4: WeakMap for Object Keys

When function arguments are objects, JSON.stringify has two problems:

  1. Wrong semantics: two distinct objects with identical shapes produce the same key. You may not want that — the identity of the object might matter.
  2. Memory leaks: the Map holds strong references to both the key (stringified) and the value. The original objects can't be garbage collected as long as the cache lives.

WeakMap keys are held weakly — when the key object has no other references, the entry is automatically garbage collected.

4

WeakMap memoize for single object arguments

function memoizeWeak(fn) {
  const cache = new WeakMap();
 
  return function memoized(arg) {
    if (typeof arg !== 'object' || arg === null) {
      throw new TypeError(
        'memoizeWeak requires an object argument — use memoize() for primitives'
      );
    }
 
    if (cache.has(arg)) {
      return cache.get(arg);
    }
    const result = fn.call(this, arg);
    cache.set(arg, result);
    return result;
  };
}
const processConfig = memoizeWeak(heavyTransform);
 
const config = { theme: 'dark', locale: 'en', features: ['a', 'b'] };
 
processConfig(config);  // computed
processConfig(config);  // cache hit — same object reference
 
// A different object with the same shape is a different key
const config2 = { theme: 'dark', locale: 'en', features: ['a', 'b'] };
processConfig(config2); // computed again — different object identity
 
// When config goes out of scope and gets GC'd,
// its WeakMap entry disappears automatically. No cleanup needed.

The semantic shift here is important: two objects with the same contents are treated as different inputs. For transformations of entities — DOM nodes, database records, request objects — this is usually what you want. You care about this specific object, not some equivalent one.

The constraint is that WeakMap keys must be objects. You can't cache results for string or number arguments. For functions that take a mix of object and primitive arguments, you'd combine a WeakMap (for object args) and a regular Map (for primitive args) into a cascading structure — one level of lookup per argument, each level choosing its map type by the argument's type.

Step 5: Memoizing Recursive Functions

This is the part that catches people. Watch what happens:

function fib(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
}
 
const memoFib = memoize(fib);
memoFib(40); // Still slow. The memoization isn't helping.

memoFib(40) hits the cache wrapper and calls the original fib(40). Inside fib, the recursive calls go to fib(n-1) and fib(n-2) — the original, unmemoized function. The cache only stores the final result for the outer call. All the sub-problem computation happens every single time.

The fix is to make the function recurse through the memoized wrapper, not itself.

5

Self-referential recursive memoization

// Pattern A: overwrite the variable binding
function fib(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);  // fib will resolve to the memoized version
}
fib = memoize(fib);
 
// Pattern B: declare with let, assign memoized from the start
let fib;
fib = memoize(function fibImpl(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);  // fib closes over the memoized wrapper
});

In both patterns, the recursive calls inside the function body resolve to the memoized wrapper. Now every sub-problem is cached on first computation and reused on every subsequent call:

let fib;
fib = memoize(function(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
});
 
fib(10);  // 11 cache misses (fib(0) through fib(10)), then done
fib(12);  // 2 cache misses (fib(11), fib(12)) — reuses everything below
fib(10);  // 0 cache misses

Compare with the non-fixed version: fib(45) without this pattern makes 3,672,623,805 calls. With it, a cold fib(45) misses 46 times — once each for fib(0) through fib(45) — and every call after that is 0 misses.

console.time('fib(45) - first');
console.log(fib(45));  // 1134903170
console.timeEnd('fib(45) - first');
 
console.time('fib(45) - second');
console.log(fib(45));
console.timeEnd('fib(45) - second');

On Node 22 the cold call lands around 0.04ms and the warm one is under a microsecond — small enough that you're mostly measuring console.time itself. The point isn't the exact number, it's the shape: linear instead of exponential.

Putting It All Together

Here's the final implementation assembled:

class LRUCache {
  constructor(maxSize) {
    this.maxSize = maxSize;
    this.cache = new Map();
  }
 
  has(key) { return this.cache.has(key); }
 
  get(key) {
    if (!this.cache.has(key)) return undefined;
    const value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value);
    return value;
  }
 
  set(key, value) {
    if (this.cache.has(key)) {
      this.cache.delete(key);
    } else if (this.cache.size >= this.maxSize) {
      this.cache.delete(this.cache.keys().next().value);
    }
    this.cache.set(key, value);
  }
}
 
function memoize(fn, { keyFn, maxSize } = {}) {
  const cache = maxSize ? new LRUCache(maxSize) : new Map();
  const getKey = keyFn ?? ((...args) => JSON.stringify(args));
 
  return function memoized(...args) {
    const key = getKey(...args);
    if (cache.has(key)) return cache.get(key);
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}
 
module.exports = { memoize };

Testing

const { memoize } = require('./memoize');
 
// Basic correctness
const square = memoize((n) => n * n);
console.assert(square(5) === 25);
console.assert(square(5) === 25);
 
// Only called once for repeated args
let calls = 0;
const tracked = memoize((x) => { calls++; return x * 2; });
tracked(3); tracked(3); tracked(3);
console.assert(calls === 1, `Expected 1 call, got ${calls}`);
 
// Different args each get computed separately
calls = 0;
tracked(4); tracked(5);
console.assert(calls === 2);
 
// LRU eviction
calls = 0;
const bounded = memoize((x) => { calls++; return x; }, { maxSize: 2 });
bounded(1); bounded(2); // cache: {1, 2}
bounded(3);             // evicts 1 — cache: {2, 3}
bounded(1);             // cache miss — 1 was evicted
console.assert(calls === 4, `Expected 4 calls, got ${calls}`);
 
// Recursive memoization
let fib;
fib = memoize(function(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
});
console.assert(fib(10) === 55);
console.assert(fib(20) === 6765);
 
// Benchmark
console.time('fib(45)');
fib(45);
console.timeEnd('fib(45)');
// Should be well under 1ms even on first call
 
console.log('All tests passed.');

What We Skipped

Async/Promise support: If fn returns a Promise, the naive implementation caches the Promise itself — which means a rejected promise stays in the cache and gets served forever. You'd want to delete the cache entry on rejection. Some libraries let you pass { async: true } to handle this.

TTL (time-based expiry): For caching API responses or data with a shelf life, you'd store the timestamp alongside the value and check it on cache reads. Either timer-based cleanup or lazy expiry on access.

Method memoization: The cache lives on the function, not the instance. Two instances share one cache, which is almost never right for class methods. For instance methods, memoize per-instance — either in the constructor or using a WeakMap keyed on this internally.

Argument normalization edge cases: Does fn(1, undefined) equal fn(1)? Should NaN === NaN for cache purposes? The standard JSON.stringify says NaN becomes null, making fn(NaN) and fn(null) collide. The keyFn option is your escape hatch.

React's useMemo / useCallback: Closest to memoize(fn, { maxSize: 1 }) scoped to one component instance, except the key is the dependency array rather than the arguments, and React owns the cache lifetime — including discarding it. Same idea, different key.

The core fits in 40 lines. The production libraries exist for the edge cases. You're now equipped to know which ones you actually need.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

You have a UserService that needs a Database. Your OrderService needs UserService, Database, and a PaymentGateway. Your NotificationService needs EmailClient and UserService.
AdminJuly 31, 20266 min read
Picture this: a live dashboard that polls for new notifications every 3 seconds. The count increments in state, the UI updates, everything looks right — except the interval callback is silently reading count = 0 on every tick, because it…
AdminJuly 31, 20266 min read
Open a modal 10 times without cleanup and you've got 10 stacked timers silently draining memory. Here's what this looks like in a PR and how senior devs catch it.
AdminJuly 31, 20266 min read