Skip to the code
cf.

DataLoader-style batch loader

Write createLoader(batchFn, options). batchFn(keys) takes an array of keys and returns a promise of an array of values, one per key, in the same order, like a function that fetches many users in one request. createLoader returns an object with one method, load(key), which returns a promise of the value for one key.

load doesn't call batchFn right away. Every load made while the current code runs joins one batch. Once that code has finished, and before any timer runs, batchFn is called once with the batch's keys, in the order they were first loaded. Each load then fulfills with the value at its key's position.

A load made after a batch has been sent starts a new batch, even while the first one is still in flight. Nothing is cached between batches, so loading a key again later asks for it again.

A batch asks for each key once: when a key is loaded twice in the same batch, both calls get its value. Keys are compared the way Map compares them.

If a value in the array is an Error, that key's load rejects with it, and the others still fulfill. If batchFn rejects or throws, every load in the batch rejects with its error. If it fulfills with anything but an array with one value per key, every load in the batch rejects with a TypeError.

options.maxBatchSize is optional. With it, a batch with more keys than that is split into several batchFn calls of at most maxBatchSize keys each, in order.

const users = createLoader(async (ids) => {
  console.log("fetching", ids);
  const found = await fetchJson("/api/users?ids=" + ids.join(","));
  return ids.map((id) => found.find((user) => user.id === id) ?? new Error("No user " + id));
});
 
const [ada, grace, again] = await Promise.all([users.load(1), users.load(2), users.load(1)]);
// logs "fetching [1, 2]" once; ada and again are user 1, grace is user 2
 
await users.load(3); // a new batch: logs "fetching [3]"
await users.load(99); // logs "fetching [99]", then rejects with Error: No user 99

What the tests check

  1. calls batchFn once, after the current code, with every key loaded
    const batchFn = spy(async (keys) => keys.map((key) => key * 10));
    const loader = createLoader(batchFn);
    loader.load(1);
    loader.load(2);
    loader.load(3);
    expect(batchFn).not.toHaveBeenCalled();
    await flush();
    expect(batchFn).toHaveBeenCalledTimes(1);
    expect(batchFn).toHaveBeenCalledWith([1, 2, 3]);
    
  2. sends the batch without waiting for a timer
    useFakeTimers(); // timers only run when the test moves the clock, and it doesn't
    const batchFn = spy(async (keys) => keys);
    const loader = createLoader(batchFn);
    loader.load("a");
    loader.load("b");
    await flush();
    expect(batchFn).toHaveBeenCalledWith(["a", "b"]);
    
  3. fulfills each load with the value at its key's position
    const loader = createLoader(async (keys) => keys.map((key) => "user " + key));
    const results = await Promise.all([loader.load(3), loader.load(1), loader.load(2)]);
    expect(results).toEqual(["user 3", "user 1", "user 2"]);
    
  4. starts a new batch for loads made after a batch was sent
    const clock = useFakeTimers();
    const batchFn = spy((keys) => new Promise((resolve) => setTimeout(resolve, 100, keys)));
    const loader = createLoader(batchFn);
    const first = loader.load(1);
    await flush();
    const later = [loader.load(2), loader.load(1)]; // the first batch is still in flight
    await flush();
    expect(batchFn).toHaveBeenCalledTimes(2);
    expect(batchFn.calls[1].args[0]).toEqual([2, 1]);
    await clock.tickAsync(100);
    await expect(first).resolves.toBe(1);
    await expect(Promise.all(later)).resolves.toEqual([2, 1]);
    
  5. asks for a repeated key once and gives every caller its value
    const batchFn = spy(async (keys) => keys.map((key) => "user " + key));
    const loader = createLoader(batchFn);
    const results = await Promise.all([loader.load(1), loader.load(2), loader.load(1)]);
    expect(batchFn).toHaveBeenCalledTimes(1);
    expect(batchFn).toHaveBeenCalledWith([1, 2]);
    expect(results).toEqual(["user 1", "user 2", "user 1"]);
    
  6. rejects only the loads whose value is an Error
    const missing = new Error("No user 2");
    const loader = createLoader(async (keys) => keys.map((key) => (key === 2 ? missing : "user " + key)));
    const [one, two, three] = await Promise.allSettled([loader.load(1), loader.load(2), loader.load(3)]);
    expect(one).toEqual({ status: "fulfilled", value: "user 1" });
    expect(two.status).toBe("rejected");
    expect(two.reason).toBe(missing);
    expect(three).toEqual({ status: "fulfilled", value: "user 3" });
    
  7. rejects every load in the batch when batchFn rejects or throws
    const error = new Error("database down");
    const rejecting = createLoader(async () => {
      throw error;
    });
    const throwing = createLoader(() => {
      throw error;
    });
    const results = await Promise.allSettled([rejecting.load(1), rejecting.load(2), throwing.load(1), throwing.load(2)]);
    for (const result of results) expect(result.reason).toBe(error);
    
  8. rejects with a TypeError unless there's one value per key
    const tooFew = createLoader(async (keys) => keys.slice(1));
    const notArray = createLoader(async () => "user 1");
    const results = await Promise.allSettled([tooFew.load(1), tooFew.load(2), notArray.load(1)]);
    for (const result of results) expect(result.reason).toBeInstanceOf(TypeError);
    
  9. splits a batch into calls of at most maxBatchSize keys
    const batchFn = spy(async (keys) => keys.map((key) => key * 10));
    const loader = createLoader(batchFn, { maxBatchSize: 2 });
    const results = await Promise.all([1, 2, 3, 4, 5].map((key) => loader.load(key)));
    expect(batchFn.calls.map((call) => call.args[0])).toEqual([[1, 2], [3, 4], [5]]);
    expect(results).toEqual([10, 20, 30, 40, 50]);
    

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