cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Memoization & laziness

Learn to memoize pure functions, choose safe cache keys, cap memory, cache async promises, and build lazy values and sequences.

By the end, you can
  • 01
    Write a safe memoizeUse a Map to prove cache hits and misses with call counters, not timing guesses.
  • 02
    Pick the right cache shapeChoose primitive keys, JSON keys, nested maps, WeakMap, async promise caches, and bounded caches deliberately.
  • 03
    Delay work honestlyUse thunks, self-replacing getters, ??=, generators, and iterator helpers to compute only what is needed.

Cache work, delay work

Memoization and laziness both ask the same practical question: can this work wait, or can we reuse a result we already computed? Memoization stores a pure function's result by its inputs. Laziness postpones a computation until code actually asks for the value.

Definition

Memoization is caching the result of a function call so the same inputs can return without doing the original work again. Lazy evaluation delays work until the value is needed, and may compute only part of a larger sequence.

This lesson builds on pure functions, closures, Map & Set, WeakMap & WeakSet, generators, and iterator helpers. If you just studied composition, memoization is the next constraint: a composed helper stays easy to reason about only when caching does not hide changing state.

Real-life analogyA library reference desk

A reference desk does not research the same stable fact again and again. It files the answer. But it also does not pull every book in the building before anyone asks. Caching and laziness are those two habits in code.

In real life: The librarian writes down a hard answer
In JavaScript: A memoized function stores a result in a cache
In real life: The same question gets the card, not another search
In JavaScript: A cache hit skips the original work
In real life: Books stay on shelves until requested
In JavaScript: Lazy values and generators compute only when pulled

Where the analogy stops: A library answer can depend on new editions and changing policies. Memoization is safe only when the function is pure for the chosen inputs, or when you deliberately expire the cache.

Build memoize with Map

STEP THROUGH

Start with the smallest honest version: one argument, a Map, and a wrapper function. On a hit, return the stored value. On a miss, call the original function, store the result, and return it. The wrapper closes over the cache, so the cache survives between calls.

Fibonacci is a useful demo because raw recursion repeats the same subproblems. We will prove the improvement with a call counter instead of a stopwatch. For fib(6), raw recursion calls the body 25 times; the memoized version calls it 7 times, one for each key from 0 through 6. A second fib(6) call adds no real work.

Memoized Fibonacci cache hits
Step 0 of 49Ready
Your turn: follow the blue line

Step through a memoized recursive function. The useful fact is not speed in milliseconds; it is the proven drop in real function calls.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  const cache = new Map();  return function memoized(arg) {    if (cache.has(arg)) return cache.get(arg);    const result = fn(arg);    cache.set(arg, result);    return result;  };} let calls = 0;let fib;fib = memoize((n) => {  calls++;  return n < 2 ? n : fib(n - 1) + fib(n - 2);}); console.log(fib(4));console.log(calls);console.log(fib(4));console.log(calls);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Choose the Fibonacci input

Changing the input starts a fresh replay. Watch `calls`: cache hits do not increment it.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Pure functions only

Memoization assumes the same inputs always mean the same output. A formatter, parser, deterministic calculation, or pure derived selector can fit. A function that reads time, random numbers, the DOM, global state, or performs a side effect needs either no memoization or an explicit invalidation policy.

Choose cache keys deliberately

CHANGE INPUT

The cache key is part of the algorithm. With one primitive argument, the argument itself is a good key. With multiple arguments, string concatenation can collide, so nested maps are safer. With objects, decide whether identity matters or whether you need a stable structural representation.

Cache key strategies
Input shapeCommon keyTrade-off
Single primitive argumentUse the value directly in a Map.Fast and clear for strings, numbers, booleans, symbols, and null/undefined.
Multiple primitive argumentsUse a deliberate compound key or nested Maps.Avoid accidental collisions like ['1','23'] versus ['12','3'].
Plain object argumentDecide whether identity or structural content matters.A Map treats two identical-looking object literals as different keys.
JSON.stringify keyWorks only when the same logical input serializes the same way.Property order, unsupported values, cycles, and non-JSON types are sharp edges.
Object-owned metadataUse WeakMap.The cache does not keep the object alive after the rest of the app releases it.
Cache key lab
single primitive cachePop out in the code editor (opens in a new tab)JavaScript
function memoize(fn) {  const cache = new Map();  return (key) => {    if (cache.has(key)) return cache.get(key);    const result = fn(key);    cache.set(key, result);    return result;  };} let calls = 0;const shout = memoize((name) => {  calls++;  return name.toUpperCase();}); console.log(shout("ada"));console.log(shout("ada"));console.log(calls);
Outputsingle primitive
  1. ADA
  2. ADA
  3. 1
Try it yourself

A primitive key is the simplest case: the second "ada" call hits the same Map entry, so the counted work stays at 1.

Every mode uses real Map or WeakMap behavior. Reset returns to the primitive-key case.

JSON.stringify can be useful when your inputs are small, acyclic, and already normalized. It is not a universal equality function. It drops some values, throws on cycles, and preserves property insertion order. Nested Maps avoid those problems when identity is the correct meaning for object arguments.

WeakMap caches and bounded caches

MEMORY

A normal Map strongly references its keys and values. If a long-running page keeps adding keys, the cache itself can become the memory leak. Use a WeakMap when the key is an object and the cached value should disappear when that object is no longer reachable elsewhere.

What WeakMap promises

WeakMap keys must be objects. The map is not iterable and has no size, because entries may be removed by garbage collection at any time after the key becomes unreachable. You cannot force or observe that collection reliably; you design around it.

Cache containers compared
ContainerWhat it meansUse it for
MapStrong references, iterable, has size.General memoization when you control cache lifetime.
Nested MapsOne map level per argument.Multiple arguments without string-concatenating keys.
WeakMapKeys must be objects and entries are not enumerable.Per-object derived data that should disappear with the object.
Size-capped MapDelete the oldest or least-recent key.Long-running pages where unbounded memoization would leak memory.

When keys are not object lifetimes, cap the cache. The tiny panel below uses a least-recently-used style rule: a hit moves the key to the end, and adding a third key to a two-entry cache deletes the oldest key.

Size-capped cache
A tiny LRU-style memoizerPop out in the code editor (opens in a new tab)JavaScript
function memoizeWithLimit(fn, limit = 2) {  const cache = new Map();  return (arg) => {    if (cache.has(arg)) {      const value = cache.get(arg);      cache.delete(arg);      cache.set(arg, value);      return value;    }    const value = fn(arg);    cache.set(arg, value);    if (cache.size > limit) {      const oldest = cache.keys().next().value;      cache.delete(oldest);    }    return value;  };} let calls = 0;const square = memoizeWithLimit((n) => {  calls++;  return n * n;}); console.log(square(2));console.log(square(3));console.log(square(2));console.log(square(4));console.log(square(3));console.log(calls);
Outputlast value is calls
  1. 4
  2. 9
  3. 4
  4. 16
  5. 9
  6. 4
Try it yourself
Limit: 2 entries

The cache keeps the most recently used keys. Calling square(4) evicts key 3, so the later square(3) must run the original function again.

A real production cache might use a library, but the small version shows the eviction rule clearly.

Memoize async work by caching the promise

PROMISES

If two components ask for the same resource at the same time, caching only the fulfilled value is too late. Cache the in-flight promise. Both callers await the same work. If the promise rejects, delete that key so a retry can make a fresh request instead of replaying a permanently failed promise.

Async memoization shares the promise
Promise-aware memoizePop out in the code editor (opens in a new tab)JavaScript
function memoizeAsync(fn) {  const cache = new Map();  return (key) => {    if (cache.has(key)) return cache.get(key);    const promise = fn(key).catch((error) => {      cache.delete(key);      throw error;    });    cache.set(key, promise);    return promise;  };} let calls = 0;const loadUser = memoizeAsync(async (id) => {  calls++;  if (id === "bad") throw new Error("not found");  return { id, name: "Ada" };}); (async () => {  const [first, second] = await Promise.all([loadUser("42"), loadUser("42")]);  console.log(first === second);  console.log(calls);  try { await loadUser("bad"); } catch {}  try { await loadUser("bad"); } catch {}  console.log(calls);})();
Outputreal promise run
    Try it yourself
    Cache promise, evict rejection

    Two simultaneous loadUser('42') calls share one in-flight promise. Failed bad loads delete their key so a retry can try again.

    The output is generated by the same async helper shown in the source. Reset reruns the demo.
    Invalidate with the data model

    Async memoization is not a full data cache. Real apps still need cache lifetimes, authorization boundaries, cancellation, and invalidation after mutations. The pattern here is the core: share the promise, then evict failures.

    Lazy values: thunks, getters, and ??=

    STEP THROUGH

    A thunk is a function you call later: () => expensiveValue(). A lazy getter hides that thunk behind a property read. A common production trick is a getter that replaces itself with a data property after the first read. For local object fields, nullish assignment ??= is the compact version of “initialize only if missing.”

    Real-life analogyA sticky note with a saved answer

    Do not work out every answer before anyone asks. Write the answer on a sticky note when it is needed, then read the same note next time.

    In real life: The sticky note is blank at first
    In JavaScript: A thunk or getter stores delayed work
    In real life: Write the answer when you need it
    In JavaScript: The first read computes the value
    In real life: Read the same note next time
    In JavaScript: The getter replaces itself or ??= stores the value

    Where the analogy stops: Writing a sticky note is visible. Lazy code can look like a normal property read, so name expensive lazy properties clearly and avoid hiding surprising side effects.

    Lazy getter and ??= initialization
    Step 0 of 14Ready
    Your turn: follow the blue line

    Lazy code delays work until a value is actually requested. A self-replacing getter and ??= both make the second read cheap.

    Running in
    1. script
    Next: line 1
    Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
      get permissions() {    console.log("compute permissions");    const value = ["read", "write"];    Object.defineProperty(this, "permissions", {      value,      enumerable: true,      configurable: true,    });    return value;  },}; console.log("before");console.log(profile.permissions.join("+"));console.log(profile.permissions.join("+")); const state = {};state.config ??= (() => {  console.log("init config");  return { theme: "dark" };})();state.config ??= { theme: "light" };console.log(state.config.theme);
    CallStoreChangeResultRun = next line. Ran = already executed.
    Recent returnsNothing yet. Start with the blue line.
    A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

    Lazy sequences with generators and iterator helpers

    COUNTERS

    Arrays are eager: map and filter visit the whole array before slice keeps the first few results. Generators are pull-based. With iterator helpers in Node 22 and current Chrome, Iterator.from(source).map(...).filter(...).take(3) reads like an array pipeline but computes only enough values to satisfy take.

    Lazy iterator helpers versus eager arrays
    Iterator helper pipelinePop out in the code editor (opens in a new tab)JavaScript
    function* naturals() {  let n = 1;  while (true) {    produced++;    yield n++;  }} let produced = 0;let mapped = 0;let tested = 0; const firstEvenSquares = Iterator.from(naturals())  .map((n) => {    mapped++;    return n * n;  })  .filter((square) => {    tested++;    return square % 2 === 0;  })  .take(3)  .toArray(); console.log(firstEvenSquares.join(","));console.log(produced + "/" + mapped + "/" + tested);
    Counter proofproduced/mapped/tested
    Lazy values4,16,36
    Lazy counters6/6/6
    Eager values4,16,36
    Eager counters1000/1000/1000
    Eager array comparisonPop out in the code editor (opens in a new tab)JavaScript
    let produced = 0;let mapped = 0;let tested = 0; const firstEvenSquares = Array.from({ length: 1000 }, (_, index) => {  produced++;  return index + 1;})  .map((n) => {    mapped++;    return n * n;  })  .filter((square) => {    tested++;    return square % 2 === 0;  })  .slice(0, 3); console.log(firstEvenSquares.join(","));console.log(produced + "/" + mapped + "/" + tested);
    Try it yourself

    The lazy pipeline pulls numbers only until it has 3 even squares. The eager array builds, maps, and filters all 1000 items before slicing.

    Node 22 and current Chrome include Iterator helpers such as map, filter, take, and toArray.

    The lazy counter line is the proof. To get three even squares, the source only has to produce numbers 1 through 6. The eager comparison produces, maps, and tests all 1000 entries before slicing the same visible values.

    Where this helps in real sites

    SORT IT

    Use memoization for expensive pure derivations: normalized search indexes, parsed routes, formatted labels, compiled regular expressions, derived permission sets, and idempotent resource loads. Use laziness for optional panels, rarely opened settings, infinite or large streams, and progressive data processing.

    Choose the cache shape
    • formatPrice('USD') for many repeated prices
    • distanceBetween(cityA, cityB)
    • metadataFor(domNode)
    • Date.now()
    • loadUser(id) that returns a promise
    • element.getBoundingClientRect() during scrolling
    Try it yourself
    0 of 6 correct

    Sort each scenario by the safest storage strategy. If the value changes over time, do not memoize it blindly.

    Choose a category for every card. You can change an answer at any time; Reset clears them all.

    A cache is part of your state model. Name it, size it, and delete from it when the business event says the old value is no longer true.

    Common misconceptions

    • “Memoization is just optimization.” It changes when code runs. With an impure function, that changes behavior.
    • “The cache key is an implementation detail.” The key defines what “same input” means.
    • “WeakMap is a smaller Map.” It is a different lifetime tool: object keys only, no iteration, no size.
    • “Lazy code is automatically faster.” It is faster only when avoided work is larger than the overhead and complexity.
    • “Generators are arrays with different syntax.” They pause and resume; they do not hold all future values.
    Misconceptions corrected
    MisconceptionBetter ruleWhy
    memoize makes any function faster.Only pure, repeated work is safe and useful.Impure functions can freeze stale data or skip needed side effects.
    Objects with the same fields are the same key.Map and WeakMap use object identity.Two object literals are different keys unless you intentionally serialize or canonicalize them.
    A cache can grow forever.Long-lived apps need eviction or a naturally bounded key space.An unbounded Map keeps keys and values alive.
    Lazy means asynchronous.Lazy means delayed until needed.A thunk, getter, generator, or ??= can be lazy while staying synchronous.
    Generators compute the whole sequence first.Generators pause between yields.take(3) can stop an infinite source after just enough values.

    Practice exercises

    5 EXERCISES
    Exercise 1 · Warm-upPredict a memoized call counter

    Read the code and type the three console outputs in order.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    function memoize(fn) {
      const cache = new Map();
      return (key) => {
        if (cache.has(key)) return cache.get(key);
        const result = fn(key);
        cache.set(key, result);
        return result;
      };
    }
    
    let calls = 0;
    const square = memoize((n) => {
      calls++;
      return n * n;
    });
    console.log(square(3));
    console.log(square(3));
    console.log(calls);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 2 · PracticeWrite the missing cache-hit branch

      Complete the helper. Then answer with the `Map` method that detects a hit.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      function memoize(fn) {
        // create a Map named cache
        return (key) => {
          // return the cached value on a hit
          // otherwise compute, store, and return
        };
      }

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 3 · PracticeFind the key bug

        The two objects look equivalent. Explain why the cache still misses.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const cache = new Map();
        const first = { id: 1, role: "admin" };
        const second = { role: "admin", id: 1 };
        cache.set(JSON.stringify(first), "saved");
        console.log(cache.get(JSON.stringify(second)) ?? "miss");

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 4 · PracticeApply async memoization to a real site

          Your product page loads `/api/user/42`. What must the helper do when that promise rejects?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          function memoizeAsync(fn) {
            const cache = new Map();
            return (key) => {
              if (cache.has(key)) return cache.get(key);
              const promise = fn(key).catch((error) => {
                cache.delete(key);
                throw error;
              });
              cache.set(key, promise);
              return promise;
            };
          }

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 5 · ChallengeProve a lazy pipeline did limited work

            Type the second console line, the counter proof for the lazy iterator pipeline.

            Starter codePop out in the code editor (opens in a new tab)JavaScript
            function* naturals() {
              let n = 1;
              while (true) {
                produced++;
                yield n++;
              }
            }
            
            let produced = 0;
            let mapped = 0;
            let tested = 0;
            
            const firstEvenSquares = Iterator.from(naturals())
              .map((n) => {
                mapped++;
                return n * n;
              })
              .filter((square) => {
                tested++;
                return square % 2 === 0;
              })
              .take(3)
              .toArray();
            
            console.log(firstEvenSquares.join(","));
            console.log(produced + "/" + mapped + "/" + tested);

            Answer, then press Check. Spacing and letter case don’t matter.

              Quiz

              7 QUESTIONS
              Memoization and laziness quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Which function is safe to memoize?

                Choose an answer to see the explanation.

              2. Question 2 of 7What does the memoized Fibonacci snippet print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                function memoize(fn) {
                  const cache = new Map();
                  return function memoized(arg) {
                    if (cache.has(arg)) return cache.get(arg);
                    const result = fn(arg);
                    cache.set(arg, result);
                    return result;
                  };
                }
                
                let calls = 0;
                let fib;
                fib = memoize((n) => {
                  calls++;
                  return n < 2 ? n : fib(n - 1) + fib(n - 2);
                });
                
                console.log(fib(6));
                console.log(calls);
                console.log(fib(6));
                console.log(calls);

                Choose an answer to see the explanation.

              3. Question 3 of 7What does the JSON key example print last?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const cache = new Map();
                const first = { id: 1, role: "admin" };
                const second = { role: "admin", id: 1 };
                cache.set(JSON.stringify(first), "saved");
                console.log(cache.get(JSON.stringify(second)) ?? "miss");

                Choose an answer to see the explanation.

              4. Question 4 of 7Why choose a WeakMap for object-keyed cache entries?

                Choose an answer to see the explanation.

              5. Question 5 of 7What should an async memoizer cache?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                function memoizeAsync(fn) {
                  const cache = new Map();
                  return (key) => {
                    if (cache.has(key)) return cache.get(key);
                    const promise = fn(key).catch((error) => {
                      cache.delete(key);
                      throw error;
                    });
                    cache.set(key, promise);
                    return promise;
                  };
                }
                
                let calls = 0;
                const loadUser = memoizeAsync(async (id) => {
                  calls++;
                  if (id === "bad") throw new Error("not found");
                  return { id, name: "Ada" };
                });
                
                (async () => {
                  const [first, second] = await Promise.all([loadUser("42"), loadUser("42")]);
                  console.log(first === second);
                  console.log(calls);
                  try { await loadUser("bad"); } catch {}
                  try { await loadUser("bad"); } catch {}
                  console.log(calls);
                })();

                Choose an answer to see the explanation.

              6. Question 6 of 7What does the lazy getter example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const profile = {
                  get permissions() {
                    console.log("compute permissions");
                    const value = ["read", "write"];
                    Object.defineProperty(this, "permissions", {
                      value,
                      enumerable: true,
                      configurable: true,
                    });
                    return value;
                  },
                };
                
                console.log("before");
                console.log(profile.permissions.join("+"));
                console.log(profile.permissions.join("+"));
                
                const state = {};
                state.config ??= (() => {
                  console.log("init config");
                  return { theme: "dark" };
                })();
                state.config ??= { theme: "light" };
                console.log(state.config.theme);

                Choose an answer to see the explanation.

              7. Question 7 of 7What do iterator helpers compute for the first three even squares?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                function* naturals() {
                  let n = 1;
                  while (true) {
                    produced++;
                    yield n++;
                  }
                }
                
                let produced = 0;
                let mapped = 0;
                let tested = 0;
                
                const firstEvenSquares = Iterator.from(naturals())
                  .map((n) => {
                    mapped++;
                    return n * n;
                  })
                  .filter((square) => {
                    tested++;
                    return square % 2 === 0;
                  })
                  .take(3)
                  .toArray();
                
                console.log(firstEvenSquares.join(","));
                console.log(produced + "/" + mapped + "/" + tested);

                Choose an answer to see the explanation.

              Key takeaways

              • Memoize only when “same inputs” is true and the cache key encodes that truth.
              • Use call counters, cache sizes, and visible outputs to prove behavior; avoid timing claims in examples.
              • Map, nested Maps, WeakMap, and size-capped caches solve different lifetime problems.
              • Async memoizers should cache the promise and evict rejected promises.
              • Lazy values and lazy sequences compute when pulled, which can avoid most of the work.

              Next, the curriculum moves toward advanced recursion. The same discipline applies there: prove what runs, keep state explicit, and choose abstractions that make the call graph easier to reason about.

              CompleteFrontend Clear concepts. Working examples.