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
memoizeSave answers in aMap, and count calls to show which ones were skipped. - 02Pick the right cache keyChoose between a plain key, a
JSON.stringifykey, nested maps, aWeakMap, a saved promise, or a size limit. - 03Wait to do work until it is neededUse thunks, getters that replace themselves,
??=, generators, and iterator helpers to work out only what is asked for.
Save work, delay work
Some work is slow, like a long calculation or a request to a server. This lesson shows two ways to do less of it:
- Save the answer. If you already worked something out, keep the answer and use it again.
- Wait until it is needed. Don't work something out until your code asks for it.
Memoization means saving the result of a function call. The next time the function gets the same inputs, it gives back the saved result instead of doing the work again. The place where saved results are kept is called a cache.
Lazy evaluation means waiting to do work until the value is needed. Sometimes only part of a long list ever gets worked out.
This lesson uses ideas from earlier lessons: pure functions, closures, Map & Set, WeakMap & WeakSet, generators, and iterator helpers. Each one is explained again when it shows up.
A librarian at the help desk hears the same questions again and again. When she finds a hard answer, she writes it on a card. The next time someone asks, she reads the card. She also doesn't take every book off the shelves before anyone asks. She fetches a book only when someone wants it.
- In real life: The librarian writes a hard answer on a card
- In JavaScript: A memoized function saves a result in the cache
- In real life: The same question gets the card, not a new search
- In JavaScript: The saved result comes back, and the slow work is skipped
- In real life: Books stay on the shelf until someone asks
- In JavaScript: Lazy code works out a value only when it is asked for
Build memoize with a Map
STEP THROUGHLet's build a helper called memoize. You give it a function. It gives back a new function, called a wrapper. The wrapper does the same job, but it saves its answers.
The wrapper keeps its answers in a Map. A Map stores pairs: a key and a value. Here the key is the argument, and the value is the answer for that argument.
Each time you call the wrapper, one of two things happens:
- A cache hit: the key is already in the
Map. The wrapper returns the saved answer. The original function does not run. - A cache miss: the key is not there yet. The wrapper calls the original function, saves the answer under the key, and returns it.
The Map is made once, inside memoize. The wrapper is a closure: a function that still remembers the variables around it. So the same Map stays alive from one call to the next.
The example uses Fibonacci numbers. Each one is the sum of the two before it: 0, 1, 1, 2, 3, 5, 8. So fib(6) is 8. A plain recursive fib works out the same smaller numbers again and again.
To show the saving, the code counts how often the real function body runs, in a variable called calls. A count is the same on every computer, so it is better proof than a stopwatch.
- Plain recursion:
fib(6)runs the body 25 times. - Memoized:
fib(6)runs the body 7 times, once for each key from 0 to 6. - A second memoized
fib(6)runs the body 0 more times, because it is a cache hit.
fib(6) runs the body and saves every answer from 0 to 6. The second fib(6) reads 8 from the cache, so calls stays at 7.Step through a memoized recursive function. Watch calls: it shows how many times the real body ran, and cache hits don't add to it.
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 is only safe for a pure function. A pure function's answer depends only on its arguments, and it changes nothing outside itself. A price formatter or a math calculation is pure.
Don't memoize a function that does any of these, unless you also clear the cache when things change:
- It reads the time or a random number.
- It reads the page (the DOM) or a global variable.
- It has a side effect: it changes something outside itself, such as a variable or a server.
function memoize(fn) {
const cache = new Map();
const memoized = (key) => {
if (cache.has(key)) return cache.get(key);
const result = fn(key);
cache.set(key, result);
return result;
};
memoized.clear = () => cache.clear();
return memoized;
}
let taxRate = 0.1;
const total = memoize((price) => Math.round(price * (1 + taxRate)));
console.log(total(100));
taxRate = 0.2;
console.log(total(100));
total.clear();
console.log(total(100));The total function reads taxRate, but taxRate is not one of its arguments. That makes it a hidden input.
- Line 15 prints
110: 100 plus 10% tax. - Line 16 changes the tax to 20%.
- Line 17 still prints
110. The key is still 100, so it is a cache hit, and the old answer comes back. - Line 18 empties the cache with the
clearmethod that line 9 added. - Line 19 is now a miss. It works out the total again and prints
120.
In a real app, clear the cache whenever the data behind it changes.
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 visits = 0;
const labelVisit = memoize((name) => {
visits++;
return name + ":" + visits;
});
console.log(labelVisit("Asha"));
console.log(labelVisit("Asha"));
console.log(visits);This function adds 1 to visits each time it runs. That is a side effect. Line 17 runs it and prints Asha:1. Line 18 is a cache hit, so the function does not run. It prints Asha:1 again, and visits stays at 1. The second visit was never counted, so don't memoize a function like this.
Choose what counts as the same input
CHANGE INPUTThe key decides when two calls count as "the same input". So pick it on purpose:
- One simple argument, such as a string or a number: use the argument itself as the key.
- Several arguments: joining them into one string can mix them up. Put one
Mapinside another instead. These are called nested maps. - An object argument: decide if you mean "this exact object" or "any object with the same fields".
Each row of this table is one kind of input. Read across to see which key to use and what to watch out for.
| What the function gets | Key to use | What to watch out for |
|---|---|---|
| One simple argument, like a string or a number. | Use the argument itself as the key in a Map. | Nothing much. This is the simplest and clearest case. |
| Several simple arguments. | Use nested Maps, or build a joined key with care. | Joined strings can mix up pairs: '1' + '23' and '12' + '3' are both '123'. |
| A plain object. | First decide: "this exact object" or "same fields"? | A Map treats two objects with the same fields as two different keys. |
An object turned into a string with JSON.stringify. | Use it only if the same input always gives the same string. | Field order changes the string. Some values are left out, and an object that points to itself throws an error. |
| Extra data that belongs to one object. | Use a WeakMap, with the object as the key. | Nothing much. When the rest of the app stops using the object, the cache lets it go too. |
const cache = new Map();
const firstUser = { id: 1 };
const secondUser = { id: 1 };
cache.set(firstUser, "saved");
console.log(cache.get(firstUser));
console.log(cache.get(secondUser) ?? "miss");- Line 4 saves the value
"saved"under the objectfirstUser. - Line 6 looks up that same object, so it prints
saved. - Line 7 looks up
secondUser. It has the same field, but it is a different object. So the lookup givesundefined. - The
??operator gives its right side when its left side isnullorundefined. So line 7 printsmiss.
A Map matches object keys by which object they are, not by what is inside them. Only build a key from the fields when your app really treats objects with the same fields as the same thing.
id: 1, but they are two different objects. Only firstUser was saved, so only its lookup prints saved.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
The key is the string "ada". The second shout("ada") finds the same key in the Map, so the real function runs only once and calls is 1.
Map or WeakMap code. Reset goes back to the one-string key.const cache = new Map();
const key = (first, second) => first + second;
cache.set(key("1", "23"), "first pair");
console.log(cache.get(key("12", "3")));Line 2 makes a key by joining two strings. On line 4, "1" and "23" become "123". On line 5, "12" and "3" also become "123". So the lookup finds the wrong entry and prints first pair. Nested maps keep the two arguments apart, so this mix-up can't happen.
JSON.stringify turns an object into a string, so you can use that string as a key. It works only for small, plain objects whose fields are always added in the same order. It has three problems:
- It leaves out some values, such as functions and
undefined. - It throws an error if an object points back to itself.
- It keeps the order the fields were added in. Two objects with the same fields in a different order give different strings.
Nested Maps avoid these problems when "the same object" is what you mean.
WeakMap caches and size limits
MEMORYA Map keeps every key and value for as long as the Map exists. Say a page stays open for hours and keeps adding keys. Then the cache keeps growing. Memory that fills up and is never freed is called a memory leak.
A WeakMap helps when the keys are objects. When nothing else in your program uses a key object any more, JavaScript may throw away that object and its cached value. Freeing unused memory like this is called garbage collection.
- Its keys must be objects.
- You can't loop over it, and it has no
size. - An entry may disappear at any time once nothing else uses its key object.
You can't force that cleanup or watch it happen. So write code that works whether the entry is still there or not.
Each row of this table is one place to keep cached answers. Read across to see how it behaves and when to use it.
| Where you keep answers | How it behaves | When to use it |
|---|---|---|
A Map. | It keeps every entry until you delete it. You can loop over it, and it has size. | Everyday memoization, when you decide when to clear it. |
Nested Maps. | There is one Map level for each argument. | Functions with several arguments, without joining them into one string. |
A WeakMap. | Keys must be objects. You can't loop over it, and it has no size. | Extra data for an object that should go away when the object does. |
A Map with a size limit. | It deletes the oldest, or least recently used, key when it gets too big. | Pages that stay open a long time, where a cache could grow without end. |
When the keys are not objects, set a size limit instead. The panel below keeps at most two answers. It follows a "least recently used" rule:
- A hit moves its key to the newest spot.
- When a third key is added, the oldest key is deleted.
A Map remembers the order its keys were added in. So cache.keys().next().value on line 13 gives the first key, which is the oldest one.
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 two keys used most recently. Calling square(4) deletes key 3, so the later square(3) has to run the real function again. That is why calls ends at 4.
function memoize(fn) {
let saved;
return () => saved ??= fn();
}
const getTags = memoize(() => ["tea"]);
const tags = getTags();
tags.push("sugar");
console.log(getTags().join("+"));This memoize saves just one answer. Line 3 uses ??=, which you'll meet again below: it runs fn() only if saved is still empty.
- Line 7 gets the saved array.
- Line 8 adds
"sugar"to it. tagsand the cache hold the same array, not two copies. So line 9 printstea+sugar.
If callers must not change cached data, return a copy. Or lock the value with Object.freeze, which stops anyone from changing it.
Memoize async work by saving the promise
PROMISESAn async function returns a promise: an object that stands for a result that will arrive later. Say two parts of a page ask for the same user at the same time. If you save only the finished result, you save it too late. The second request has already started before the first one ends.
So save the promise itself, right away. Then:
- The second caller gets the same promise, and both wait for the same request.
- If the promise fails (we say it is rejected), delete its key. Then a retry makes a new request instead of getting the same failed promise back.
first === second is true and calls is 1.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 loadUser('42') calls at the same time share one saved promise. Each failed bad load deletes its key, so a retry can try again.
- Line 4 returns the saved promise on a hit.
- Lines 5 to 8 add a
.catchhandler. If the promise is rejected, it deletes the key, then throws the error again so the caller still sees it. - Line 9 saves the promise before it finishes.
- The output is
true(both callers got the same user object), then1(the loader ran once), then3(each failed retry ran the loader again).
This helper is not a full data cache. A real app also needs:
- a time limit, after which saved data is fetched again,
- a separate cache for each signed-in user,
- a way to cancel a request,
- clearing the cache after data changes on the server.
The idea here is the core of all of them: share the promise, and delete failures.
Lazy values: thunks, getters, and ??=
STEP THROUGHHere are three ways to wait until a value is needed:
- A thunk is a function you call later to get a value, like
() => expensiveValue(). Nothing is worked out until you call it. - A getter is a function that runs when you read a property. You write it as
get name() { … }inside an object. A lazy getter works out the value on the first read. Then it replaces itself with a normal property that holds the value, so later reads skip the work. ??=is the nullish assignment operator.a ??= bsetsatobonly ifaisnullorundefined. It means "fill this in only if it is empty".
You don't work out every answer before anyone asks. When you need an answer, you write it on a sticky note. The next time, you just read the note.
- In real life: The sticky note is blank at first
- In JavaScript: A thunk or getter holds work for later
- In real life: You write the answer when you need it
- In JavaScript: The first read works out the value
- In real life: Next time, you read the same note
- In JavaScript: The getter replaces itself, or
??=keeps the value
Lazy code waits to do work until a value is asked for. A getter that replaces itself 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);Line 5 uses Object.defineProperty, which adds or replaces a property on an object. Here it swaps the getter for a normal property that holds the array. That is why compute permissions prints only once. On line 23, state.config already holds an object, so the second ??= does nothing and the theme stays dark.
Lazy lists with generators and iterator helpers
COUNTERSArray methods are eager: each one does its whole job right away. map visits every item. Then filter visits every item. Only then does slice keep the first few.
A generator is a function written with function*. It gives out values one at a time with yield. After each one, it pauses until someone asks for the next value.
Iterator helpers are methods such as map, filter, and take that work on a one-at-a-time source like a generator. Node 22 and current Chrome have them.
Iterator.from(source)wraps the source so you can use these methods.take(3)stops after 3 values.toArray()collects the values into an array.
The chain reads like array code. But each number goes through the whole chain before the next number is made.
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 chain makes numbers only until it has 3 even squares. The eager array makes, squares, and tests all 1000 numbers before it keeps the first 3.
map, filter, take, and toArray.Look at the counter lines. The squares of 1 to 6 are 1, 4, 9, 16, 25, and 36. Three of them are even: 4, 16, and 36. So the lazy chain only has to make the numbers 1 to 6. The eager array makes, squares, and tests all 1000 numbers before it keeps the same three.
4,16,36. The lazy chain stops once it has three even squares. The eager array works on all 1000 numbers first.Where this helps on real sites
SORT ITUse memoization for slow work whose answer depends only on its inputs, such as:
- building a search index from a list of products,
- reading a page address and working out which page to show,
- formatting labels and prices,
- building a regular expression (a text pattern) once and reusing it,
- working out what a user is allowed to do,
- loading a resource that gives the same result every time.
Use laziness for:
- panels that most people never open,
- settings that are rarely read,
- very long or endless lists,
- data you handle one piece at a time.
formatPrice('USD') for many repeated pricesdistanceBetween(cityA, cityB)metadataFor(domNode)Date.now()loadUser(id) that returns a promiseelement.getBoundingClientRect() during scrolling
Put each case in the safest place to keep its answers. If the answer changes over time, don't memoize it.
A cache holds part of your app's data. Give it a clear name, keep it small, and delete entries as soon as their data is out of date.
Common misconceptions
- "Memoization only makes code faster." It also changes when, and how often, the function runs. If the function is not pure, that changes what your program does.
- "The cache key is a small detail." The key decides what counts as "the same input".
- "A WeakMap is a smaller Map." It is a different tool, about how long entries live. It takes only object keys, you can't loop over it, and it has no
size. - "Lazy code is always faster." It is faster only when the work it skips is bigger than the extra code it adds.
- "Generators are arrays written another way." A generator pauses and goes on. It does not hold all its future values.
Each row of this table is a common wrong idea. Read across for the better rule and the reason.
| Wrong idea | Better rule | Why |
|---|---|---|
memoize makes any function faster. | Memoize only pure functions that get the same input again and again. | If the function is not pure, the cache can give back old data or skip work that had to happen. |
| Objects with the same fields are the same key. | Map and WeakMap match object keys by which object they are. | Two separate object literals are two keys, unless you turn them into the same string on purpose. |
| A cache can grow forever. | A page that stays open needs a size limit, or only a few possible keys. | A Map with no limit keeps all its keys and values in memory. |
| Lazy means async. | Lazy means "wait until it is needed". | A thunk, a getter, a generator, or ??= can be lazy and still run right away when asked. |
| A generator works out the whole list first. | A generator pauses after each yield. | take(3) can stop an endless source after just enough values. |
Practice exercises
5 EXERCISESRead the code. Type the three lines it prints, 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. The second one is a cache hit, so the original function runs only once. The three lines are 9, 9, and 1.
Fill in the helper. Then type the name of the Map method that tells you a key is already saved.
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. On a hit, it returns the saved answer. On a miss, it calls fn, saves the result, and returns it.
The two objects look the same. Explain why the lookup 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 two strings are different because the fields are in a different order. To fix it, sort the fields before making the string, or use nested maps if you mean "the same object".
Your product page loads /api/user/42. What must the helper do when that promise is rejected?
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;
};
}Save the promise right away, so two callers at the same time share it. In .catch, delete that key and throw the error again. Then a later retry calls the loader again.
Type the second line the code prints: the counters for the lazy chain.
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 chain makes the numbers 1 to 6 to find three even squares. So produced, mapped, and tested are all 6, and the line prints 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 code 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 use a
WeakMapfor a cache whose keys are objects?Choose an answer to see the explanation.
Question 5 of 7What does the async memoization code print?
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 does the lazy chain print 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 a function only if the same inputs always give the same answer. Pick a key that says what "the same inputs" means.
- Show what happens with call counters and printed output, not with timings.
- A
Map, nestedMaps, aWeakMap, and aMapwith a size limit each keep answers for a different length of time. Pick the one that fits. - For async work, save the promise right away, and delete it if it is rejected.
- Lazy values and lazy lists do work only when asked, so they can skip most of the work.
Next comes advanced recursion. The same habits help there: count which calls really run, and keep saved data where you can see it.