Memoization means saving a function's result for the arguments it was called with, so that calling it again with the same arguments returns the saved result instead of running the function again. The saved results are the function's cache. When a call finds its result there, that's a cache hit; when it doesn't, it's a miss, and the function runs and its result is saved. Memoizing trades memory for time, so it pays off for functions that are slow and get called with the same arguments again and again.
It's like writing the answer to a long sum in the margin, so the next time someone asks, you read it off instead of working it out. Take countPrimes(n), which counts the prime numbers below n and is slow when n is large. Here's what a memoized version does over four calls, next to a lighter variant that remembers only its most recent call:
| Call | Memoized: remembers every call | Remembers only the last call |
|---|---|---|
countPrimes(1000000) | Miss: runs, returns 78498, and saves it. | Miss: runs, returns 78498, and saves it. |
countPrimes(1000000) | Hit: returns the saved 78498 without running. | Hit: returns the saved 78498 without running. |
countPrimes(2000000) | Miss: runs, returns 148933, and saves it too. | Miss: runs, returns 148933, and saves it in place of 78498. |
countPrimes(1000000) | Hit: 78498 is still saved. | Miss: only the result for 2000000 is saved, so it runs again. |
Every saved result, with the arguments it was saved for, stays in memory until the cache lets go of it, and a cache that never does grows with every new argument. Memoize helpers deal with that in different ways: some let you clear the cache, some cap its size and drop the least recently used result when it's full, and the variant in the last column keeps only one. That one suits a value recomputed again and again from inputs that rarely change. React's useMemo works that way: it keeps the value from the last render and recomputes it only when one of its dependencies has changed.
A cache finds results by a key worked out from the arguments. Lodash's _.memoize uses the first argument as the key unless you pass a resolver, a function that turns the arguments into a key. Without one, a memoized power(base, exponent) called as power(2, 3) and then power(2, 10) returns 8 both times: both calls have the key 2. Lodash compares keys the way a Map does, so an object matches only the very same object: a different object with the same contents is a miss, and changing an object's contents doesn't make it miss. Other helpers build the key from every argument.
Only memoize pure functions: ones whose result depends on nothing but their arguments, and that don't change anything outside themselves. A hit skips the function entirely. If getPrice(id) reads a price that has since changed, the memoized version keeps returning the old one, and a function that logs something or sends a request doesn't do it on a hit.
For an async function, the result is a promise, so the promise is what gets saved. A second call with the same arguments gets the same promise, even while it's still pending, so two parts of a page asking for the same user share one request. A promise that rejects would be saved too, and every later call would get the same failure, unless the cache forgets keys whose promise rejects.
Besides useMemo, you meet memoization in React's memo, which skips re-rendering a component whose props haven't changed, in selector libraries such as Reselect, and in recursive algorithms that would otherwise solve the same smaller problem many times.
A request is in flight from the moment it's sent until its response arrives or it fails. Deduplicating, or deduping, in-flight requests means that when a caller asks for something while a request for that same thing is still in flight, no new request is sent. The caller shares the request already on its way, and gets the same result when it arrives.
It matters because the parts of a page don't know about each other. The header, the profile card, and the comment box may each ask for user 7 as the page loads. Without deduping, that's three identical requests; with it, one. Requests count as the same when they have the same key, such as the URL or the user ID:
| Time | Event | What happens |
|---|---|---|
| 0 ms | getUser(7) is called. | Nothing is in flight for 7, so a request is sent. |
| 10 ms | getUser(7) is called. | A request for 7 is in flight, so this caller shares it. |
| 20 ms | getUser(8) is called. | A different key, so a second request is sent. |
| 200 ms | The response for 7 arrives. | Both callers for 7 get the same user, and 7 is forgotten. |
| 300 ms | getUser(7) is called. | Nothing is in flight for 7 any more, so a new request is sent. |
If a shared request fails, everyone sharing it gets the failure, and the key is forgotten all the same, so the next call tries again.
Deduping isn't caching. It only merges requests that overlap in time: once the response arrives, the key is forgotten, as at 300 ms above, so later calls get fresh data. A cache keeps the result and answers later calls from it without a request, which is faster but can hand out data that's out of date. A memoized async function, one that keeps its result for each argument it has seen, does both: callers share a request while it's in flight, and its result is kept afterwards.
Data-fetching libraries such as TanStack Query and SWR dedupe requests for the same key, and cache the results as well, which is why components can each ask for the data they need without coordinating with each other.
Write memoizeAsync(fn), where fn returns a promise. It returns a function that calls fn with all its arguments, always returns a promise, and caches by its first argument, the key, compared the way a Map compares keys.
The first call with a key calls fn right away. Calls with the same key while that call is pending don't call fn again: they settle the same way it does. Once it has resolved, calls with that key resolve with the same value, still without calling fn.
If the promise rejects, every caller waiting on it gets the rejection, and the key is forgotten, so the next call with it calls fn again. If fn throws instead of returning a promise, the memoized function doesn't throw: it returns a rejected promise, and the key is forgotten the same way.
The memoized function also has a clear() method, which forgets every key. A call still pending at clear() settles as usual for its callers, but it no longer adds anything to the cache or removes anything from it.
const loadUser = (id) => fetch("/api/users/" + id).then((response) => response.json());
const getUser = memoizeAsync(loadUser);
await Promise.all([getUser(1), getUser(1)]); // one request, shared
await getUser(1); // no request: the result is cached
await getUser(2); // a new request for a new key
const fn = spy(async (id, options) => id + ":" + options.fields);
const load = memoizeAsync(fn);
await expect(load(1, { fields: "name" })).resolves.toBe("1:name");
expect(fn).toHaveBeenCalledWith(1, { fields: "name" });
const pending = [];
const fn = spy(() => new Promise((resolve) => pending.push(resolve)));
const getUser = memoizeAsync(fn);
const a = getUser(1);
const b = getUser(1);
expect(fn).toHaveBeenCalledTimes(1);
pending[0]("Ada");
expect(await a).toBe("Ada");
expect(await b).toBe("Ada");
const fn = spy(async (id) => "user " + id);
const getUser = memoizeAsync(fn);
const results = await Promise.all([getUser(1), getUser(2)]);
expect(results).toEqual(["user 1", "user 2"]);
expect(fn).toHaveBeenCalledTimes(2);
const fn = spy(async (id) => "user " + id);
const getUser = memoizeAsync(fn);
expect(await getUser(1)).toBe("user 1");
expect(await getUser(1)).toBe("user 1");
expect(await getUser(1)).toBe("user 1");
expect(fn).toHaveBeenCalledTimes(1);
const pending = [];
const fn = spy(() => new Promise((resolve, reject) => pending.push(reject)));
const getUser = memoizeAsync(fn);
const a = getUser(1);
const b = getUser(1);
pending[0](new Error("offline"));
await expect(a).rejects.toThrow("offline");
await expect(b).rejects.toThrow("offline");
let attempts = 0;
const fn = spy(async () => {
attempts++;
if (attempts === 1) throw new Error("flaky");
return "ok";
});
const load = memoizeAsync(fn);
await expect(load("a")).rejects.toThrow("flaky");
await expect(load("a")).resolves.toBe("ok");
expect(fn).toHaveBeenCalledTimes(2);
const fn = spy((id) => {
if (id < 0) throw new RangeError("bad id");
return Promise.resolve(id);
});
const load = memoizeAsync(fn);
let result;
expect(() => {
result = load(-1);
}).not.toThrow();
await expect(result).rejects.toThrow(RangeError);
await expect(load(-1)).rejects.toThrow(RangeError);
expect(fn).toHaveBeenCalledTimes(2);
const pending = [];
const fn = spy(() => new Promise((resolve) => pending.push(resolve)));
const load = memoizeAsync(fn);
const one = load(1);
pending[0]("one");
await one;
const two = load(2);
load.clear();
pending[1]("two");
expect(await two).toBe("two");
load(1);
load(2);
expect(fn).toHaveBeenCalledTimes(4);
const pending = [];
const fn = spy(() => new Promise((resolve, reject) => pending.push({ resolve, reject })));
const load = memoizeAsync(fn);
const stale = load(1);
load.clear();
const fresh = load(1);
expect(fn).toHaveBeenCalledTimes(2);
pending[0].reject(new Error("stale"));
await expect(stale).rejects.toThrow("stale");
const again = load(1);
expect(fn).toHaveBeenCalledTimes(2);
pending[1].resolve("fresh");
expect(await fresh).toBe("fresh");
expect(await again).toBe("fresh");
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Memoize an async function. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.