Memoization & laziness
Learn to memoize pure functions, choose safe cache keys, cap memory, cache async promises, and build lazy values and sequences.
- 01Write a safe
memoizeUse aMapto prove cache hits and misses with call counters, not timing guesses. - 02Pick the right cache shapeChoose primitive keys, JSON keys, nested maps,
WeakMap, async promise caches, and bounded caches deliberately. - 03Delay 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.
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.
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 THROUGHStart 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.
Step through a memoized recursive function. The useful fact is not speed in milliseconds; it is the proven drop in real function calls.
script
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);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 INPUTThe 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.
| Input shape | Common key | Trade-off |
|---|---|---|
| Single primitive argument | Use the value directly in a Map. | Fast and clear for strings, numbers, booleans, symbols, and null/undefined. |
| Multiple primitive arguments | Use a deliberate compound key or nested Maps. | Avoid accidental collisions like ['1','23'] versus ['12','3']. |
| Plain object argument | Decide whether identity or structural content matters. | A Map treats two identical-looking object literals as different keys. |
JSON.stringify key | Works only when the same logical input serializes the same way. | Property order, unsupported values, cycles, and non-JSON types are sharp edges. |
| Object-owned metadata | Use WeakMap. | The cache does not keep the object alive after the rest of the app releases it. |
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);ADAADA1
A primitive key is the simplest case: the second "ada" call hits the same Map entry, so the counted work stays at 1.
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
MEMORYA 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.
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.
| Container | What it means | Use it for |
|---|---|---|
Map | Strong references, iterable, has size. | General memoization when you control cache lifetime. |
Nested Maps | One map level per argument. | Multiple arguments without string-concatenating keys. |
WeakMap | Keys must be objects and entries are not enumerable. | Per-object derived data that should disappear with the object. |
Size-capped Map | Delete 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.
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);4941694
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.
Memoize async work by caching the promise
PROMISESIf 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.
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);})();Two simultaneous loadUser('42') calls share one in-flight promise. Failed bad loads delete their key so a retry can try again.
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 THROUGHA 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.”
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 code delays work until a value is actually requested. A self-replacing getter and ??= both make the second read cheap.
script
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);Lazy sequences with generators and iterator helpers
COUNTERSArrays 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.
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);4,16,366/6/64,16,361000/1000/1000let 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);The lazy pipeline pulls numbers only until it has 3 even squares. The eager array builds, maps, and filters all 1000 items before slicing.
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 ITUse 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.
formatPrice('USD') for many repeated pricesdistanceBetween(cityA, cityB)metadataFor(domNode)Date.now()loadUser(id) that returns a promiseelement.getBoundingClientRect() during scrolling
Sort each scenario by the safest storage strategy. If the value changes over time, do not memoize it blindly.
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.
| Misconception | Better rule | Why |
|---|---|---|
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 EXERCISESRead the code and type the three console outputs in order.
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);Both calls return 9, but the original function runs once, so the logs are 9, 9, and 1.
Complete the helper. Then answer with the `Map` method that detects a hit.
function memoize(fn) {
// create a Map named cache
return (key) => {
// return the cached value on a hit
// otherwise compute, store, and return
};
}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;
};
}The wrapper checks cache.has(key) first. Only a miss calls fn, stores the result, and returns it.
The two objects look equivalent. Explain why the cache still misses.
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");The miss happens because the JSON strings have different property order. Normalize keys first, use a stable serializer, or choose nested maps if identity is the right meaning.
Your product page loads `/api/user/42`. What must the helper do when that promise rejects?
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;
};
}Store the promise immediately so concurrent callers share it. In .catch, delete that key and rethrow so later retries can call the loader again.
Type the second console line, the counter proof for the lazy iterator pipeline.
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);The pipeline pulls source numbers 1 through 6 to find three even squares. So produced, mapped, and tested are all 6, printing 6/6/6.
Quiz
7 QUESTIONSQuestion 1 of 7Which function is safe to memoize?
Choose an answer to see the explanation.
Question 2 of 7What does the memoized Fibonacci snippet print?
Read the code, then predictfunction 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.
Question 3 of 7What does the JSON key example print last?
Read the code, then predictconst 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.
Question 4 of 7Why choose a
WeakMapfor object-keyed cache entries?Choose an answer to see the explanation.
Question 5 of 7What should an async memoizer cache?
Read the code, then predictfunction 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.
Question 6 of 7What does the lazy getter example print?
Read the code, then predictconst 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.
Question 7 of 7What do iterator helpers compute for the first three even squares?
Read the code, then predictfunction* 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, nestedMaps,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.