Limit promise concurrency in JavaScript

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?

Try implementing it before reading the solution. The JavaScript interview practice editor lets you write your answer and run the question’s tests.

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, in your browser or on your desktop. Twenty-four questions, and an AI panel that explains why a test failed.

See all 24 questions