Skip to the code
cf.

Memoize an async function

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

What the tests check

  1. calls fn with all the arguments and resolves with its result
    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" });
    
  2. shares one pending call between callers with the same key
    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");
    
  3. calls fn separately for different keys
    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);
    
  4. caches the resolved value
    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);
    
  5. a rejection reaches every caller waiting on it
    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");
    
  6. forgets a rejected key, so the next call tries again
    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);
    
  7. treats a synchronous throw like a rejection
    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);
    
  8. clear() forgets every key, even a pending one
    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);
    
  9. a failure that ends after clear() doesn't remove the newer call
    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");
    

Learn more

solution.jsSaved in this browser

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).