Limit promise concurrency

To map asynchronously with a concurrency limit, start exactly size workers and have each pull the next index from a shared cursor until the list runs out. Write results into results[index] so input order survives, and await all the workers with Promise.all.

Easy to hard async/await promises concurrency control

The question

The realistic version of "why is Promise.all(urls.map(fetch)) a problem". Firing a thousand requests at once exhausts sockets, trips rate limits and helps nobody. The question is how to cap it, and the follow-ups are:

  1. Does the output stay in input order?
  2. Is the limit actually never exceeded?
  3. What happens when one callback rejects?

The solution

The worker-pool shape is much easier to reason about than batching, and it keeps the pool saturated instead of idling at the end of each batch.

async function mapAsyncLimit(iterable, callbackFn, size) {
  const items = Array.from(iterable);
  const results = new Array(items.length);
  let next = 0;

  // Each worker pulls the next index until the list is exhausted, so no more
  // than `size` callbacks are ever in flight.
  async function worker() {
    while (next < items.length) {
      const index = next;
      next += 1;
      results[index] = await callbackFn(items[index], index, items);
    }
  }

  const workers = [];
  for (let i = 0; i < Math.max(1, Math.min(size, items.length)); i += 1) {
    workers.push(worker());
  }

  // Promise.all rejects on the first failing worker, matching the callback's error.
  await Promise.all(workers);
  return results;
}

How it works

The tests it has to pass

This is the part most explanations leave out. "It works" is not a claim you can check by reading. These are the 7 cases Practice Pad runs against your solution, and the implementation above passes all of them.

Easy2 cases · mapping and order

maps every value and keeps input order

const doubled = await mapAsyncLimit([1, 2, 3, 4], async (value) => value * 2, 2);
expectEqual(doubled, [2, 4, 6, 8]);

awaits each callback and returns the resolved values

const upper = await mapAsyncLimit(
  ["a", "b", "c"],
  async (value) => {
    await delay(5);
    return value.toUpperCase();
  },
  2,
);
expectEqual(upper, ["A", "B", "C"]);
Medium3 cases · edges and sequencing

returns an empty array for empty input

expectEqual(await mapAsyncLimit([], async (value) => value, 3), []);

runs one callback at a time when the limit is one

const order = [];
const results = await mapAsyncLimit(
  [30, 5, 10],
  async (ms) => {
    order.push("start:" + String(ms));
    await delay(ms);
    order.push("end:" + String(ms));
    return ms;
  },
  1,
);
expectEqual(results, [30, 5, 10]);
expectEqual(order, ["start:30", "end:30", "start:5", "end:5", "start:10", "end:10"]);

rejects when a callback fails

let message;
try {
  await mapAsyncLimit([1, 2], async () => { throw new Error("mapper failed"); }, 2);
} catch (error) {
  message = error && error.message;
}
expectEqual(message, "mapper failed");
Hard2 cases · the concurrency guarantee

never runs more callbacks at once than the limit allows

let active = 0;
let peak = 0;
const results = await mapAsyncLimit(
  [40, 10, 30, 20],
  async (ms) => {
    active += 1;
    peak = Math.max(peak, active);
    await delay(ms);
    active -= 1;
    return ms;
  },
  2,
);
expectEqual(results, [40, 10, 30, 20]);
expectEqual(peak <= 2, true);

keeps order even when earlier values take longer

const ordered = await mapAsyncLimit(
  [30, 5],
  async (ms) => {
    await delay(ms);
    return ms;
  },
  2,
);
expectEqual(ordered, [30, 5]);

Edge cases to watch out for

Batching instead of pooling

Splitting into chunks of size and awaiting each chunk is simpler, and wastes time: the whole batch waits for its slowest member before the next one starts. A pool keeps every slot busy.

Pushing results instead of assigning by index

With varying durations, push order is completion order. The last hard test uses a slow first item and a fast second precisely to catch this.

Reading the cursor after the await

Capturing index after awaiting means every worker sees the final value. Take the index and increment the cursor before any await.

On error handling: Promise.all rejects on the first failure but doesn’t stop the other workers, which keep consuming the queue. If you need them to stop, share an aborted flag that each worker checks before taking the next index.

Follow up questions

How does this compare to p-limit?

Same idea, more edges handled. p-limit gives you a reusable limiter you can share across call sites rather than one bound to a single array, and it exposes queue introspection. If you already have it as a dependency, use it. Interviewers ask for the hand-rolled version because the worker-pool shape is the thing being tested.

How do you keep results in input order?

Capture each item's index before awaiting, and assign into results[index] when the callback resolves. Never push, because push order is completion order, which only matches input order by coincidence.

What’s the difference between batching and a worker pool?

Batching processes fixed chunks and waits for each chunk to finish entirely before starting the next, so the whole batch idles behind its slowest member. A pool keeps a fixed number of slots busy continuously, which finishes sooner whenever durations vary.

Practise this with the tests running

Practice Pad runs these 7 cases against your own solution, in a real editor, on your Mac. Twenty-four questions, and an optional AI panel that explains why a test failed.

Download for macOS Free. Requires macOS 12 or later on Apple Silicon.

See all 24 questions