AlgoInsight

Free lesson · JavaScript Basic to Advance

Memoization with Closures

Easy5 min read

What Is Memoization?

Memoization is a technique for caching the results of expensive function calls. The idea is simple: if a function is called with the same inputs multiple times, compute the result once and remember it for next time. Subsequent calls with the same inputs return the cached result instantly.

Closures are the natural mechanism for memoization in JavaScript. The cache lives inside the closure, private and persistent across calls.

function memoize(fn) {
  const cache = new Map();

  return function(...args) {
    const key = JSON.stringify(args);

    if (cache.has(key)) {
      return cache.get(key);
    }

    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

// An expensive computation: simulate slow work with a fake delay measurement
function slowSquare(n) {
  console.log(`Actually computing ${n} squared...`);
  return n * n;
}

const fastSquare = memoize(slowSquare);

console.log(fastSquare(5));   // "Actually computing 5 squared..." then 25
console.log(fastSquare(5));   // 25 — no computation, returned from cache
console.log(fastSquare(5));   // 25 — still cached
console.log(fastSquare(6));   // "Actually computing 6 squared..." then 36
console.log(fastSquare(5));   // 25 — still cached
console.log(fastSquare(6));   // 36 — cached now too

The slowSquare function only runs once per unique input. After the first call with 5, the result is cached. Subsequent calls with 5 skip the computation entirely. This is memoization — pure functions become cacheable by virtue of the same-input-same-output property.

This Topic shows you how memoize works, why closures are essential to it, and when memoization actually helps versus when it hurts.

Interactive animation: The cache, growing - each unique input is computed once on a miss, and every repeat is a hit.

How Memoization Works

A memoization wrapper needs three things:

  1. A place to store cached results — usually a Map or plain object
  2. A way to derive a cache key from the function's arguments
  3. A way to check the cache and compute on miss

The closure provides the first thing for free. The cache variable lives inside the wrapper, persists across calls to the wrapped function, and isn't visible from outside. This is exactly the privacy and persistence we covered in Topic 6.4 — applied to a specific use case.

graph TD
    A[memoize fn called] --> B[Creates cache map<br/>via closure]
    B --> C[Returns wrapped function]
    C --> D{Wrapped function called<br/>with args}
    D --> E[Compute cache key<br/>from args]
    E --> F{Key in cache?}
    F -->|Yes| G[Return cached result]
    F -->|No| H[Call original fn]
    H --> I[Store result in cache]
    I --> J[Return result]

    style B fill:#7c3aed,color:#fff
    style G fill:#10b981,color:#fff
    style J fill:#10b981,color:#fff
This diagram shows how a memoization wrapper works. When memoize is called with a function, it creates a cache map using a closure. It returns a wrapped function that, when called, computes a cache key from its arguments, checks if the key is already in the cache, and either returns the cached result or computes a new one and stores it. The cache persists across calls because of the closure.

When Memoization Pays Off

Memoization is a trade-off. You're spending memory (the cache) to save time (the computation). It pays off when:

1. The computation is expensive. If a function takes milliseconds or longer per call, caching makes sense. If the function is already fast (n * n is essentially instant), the overhead of cache lookup can be slower than just recomputing.

2. The same inputs come up repeatedly. Memoization only helps when there are cache hits. A function called with completely different inputs every time gets no benefit.

3. The function is pure. Memoization assumes the function always returns the same output for the same input. If the function has side effects or depends on external state, caching produces wrong results.

A classic example where memoization shines: recursive algorithms with overlapping subproblems. The Fibonacci sequence is the canonical example:

// Without memoization — terrible performance
function fibSlow(n) {
  if (n <= 1) return n;
  return fibSlow(n - 1) + fibSlow(n - 2);
}

// With memoization — vastly faster
const fibFast = memoize(function fib(n) {
  if (n <= 1) return n;
  return fibFast(n - 1) + fibFast(n - 2);
});

console.time("slow");
console.log(fibSlow(30));
console.timeEnd("slow");

console.time("fast");
console.log(fibFast(30));
console.timeEnd("fast");

function memoize(fn) {
  const cache = new Map();
  return function(...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

The slow version makes the same fibSlow(28), fibSlow(27), etc. calls many times — each branch of the recursion recomputes everything below it. The fast version computes each fib(k) exactly once and reuses the result. The difference at n = 30 is huge; at n = 40 it's the difference between instant and "the browser tab froze."

The Cache Key Problem

Memoization needs a way to identify "same arguments" — to know that this call has the same inputs as a previous one. For simple cases, JSON.stringify(args) works:

function memoize(fn) {
  const cache = new Map();
  return function(...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

const memoized = memoize((a, b) => a + b);
console.log(memoized(1, 2));   // computes, caches
console.log(memoized(1, 2));   // cached
console.log(memoized(2, 1));   // different args, computes

But JSON.stringify has limitations:

For production code, libraries like Lodash's _.memoize use more sophisticated key resolution. For interview code and simple cases, JSON.stringify(args) is the standard approach.

When Memoization Hurts

Memoization isn't free. It uses memory, and the cache itself adds overhead per call. Cases where it's the wrong tool:

Functions with many unique inputs. If you call a function a million times with a million different inputs, the cache grows to a million entries — and never gets a hit. You've used memory for no benefit.

Functions with very fast computations. A cache lookup involves computing the key, hashing it, looking it up in the map. For a function like add(a, b), this overhead is greater than just doing a + b. Memoization adds latency rather than removing it.

Functions with side effects. If a function logs, modifies external state, or causes network calls, memoization will only do those things on the first call. Subsequent calls return the cached value without re-running the side effects. This is almost always a bug.

Long-lived applications without cache eviction. A memoization cache that never clears can grow indefinitely. In a long-running application, this is a memory leak. Real-world memoization often uses a Least-Recently-Used (LRU) cache with a maximum size.

Interactive animation: Three questions before you memoize - is it pure, is it actually slow, and will the cache grow?

A Custom Memoize for Specific Cases

For some cases, you might want a custom memoizer with specific behavior — maybe a maximum cache size, or a custom key function.

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

  return function(...args) {
    const key = JSON.stringify(args);

    if (cache.has(key)) {
      return cache.get(key);
    }

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

    // Evict oldest entry if cache exceeds maxSize
    if (cache.size > maxSize) {
      const firstKey = cache.keys().next().value;
      cache.delete(firstKey);
    }

    return result;
  };
}

const limited = memoizeWithLimit(n => n * 2, 3);

console.log(limited(1));   // 2 — cached, cache: {1: 2}
console.log(limited(2));   // 4 — cached, cache: {1: 2, 2: 4}
console.log(limited(3));   // 6 — cached, cache: {1: 2, 2: 4, 3: 6}
console.log(limited(4));   // 8 — cached, but cache evicts 1, cache: {2: 4, 3: 6, 4: 8}
console.log(limited(1));   // 2 — recomputed because evicted earlier

This is closer to what a real LRU cache library does — bounded size, with a strategy for choosing what to evict.

Common Pitfalls

Interactive animation: The contract - only pure functions are safe to memoize, since side effects and outside state make cached answers wrong.

Warning: The memoization traps.

Memoizing functions with side effects. The wrapped function only runs once per unique input. Side effects only happen once. If your function was supposed to log every call, memoization breaks it.

Using object references as keys without thinking. JSON.stringify produces the same key for {a: 1} and {a: 1} even though they're different object instances. Sometimes this is what you want; sometimes it isn't.

Unbounded cache growth. A naive memoize never frees memory. For long-running applications or hot paths with unique inputs, this is a leak. Use a bounded cache (like the example above) or a library with LRU eviction.

Why This Matters in Interviews

Interview: "Implement memoize" is a common JavaScript interview question, especially at mid-to-senior levels. It tests three things: do you understand closures (the cache lives in one), do you understand higher-order functions (memoize takes and returns functions), and do you understand the practical trade-offs?

Senior follow-ups often probe the edge cases: "How would you handle object arguments?", "What about functions with this?", "How would you add a cache size limit?" These are real production concerns.

Topic 6.7 — Interview Lens: Closures — covers the deeper treatment, including this exact implementation challenge.

Try It

Exercise: Implement memoize from scratch, then use it on a function of your choice. Verify that the wrapped function only runs once per unique input (use a console.log inside the function being memoized to make this visible).

Then, harder: implement a memoize that supports a custom key function as a second parameter:

function memoize(fn, keyFn = (...args) => JSON.stringify(args)) {
  // your code
}

const fnByFirstArg = memoize(
  (a, b) => a + b,
  (a, b) => a   // only first arg matters for caching
);

The custom key function lets you cache based on whatever criteria you want, not just the full argument list. This is the kind of flexibility libraries like Lodash provide.

Back to JavaScript Basic to Advance