skip to content

Implement `mapWithConcurrency(items, limit, worker)`, which keeps at most `limit` calls to the async `worker` running at once and resolves to an array of results in the original `items` order. How does your implementation work?

level: middleimportance: must knowfreq 55%

answer

  1. a few loops, not one promise per item
  2. shared cursor claims an index
  3. assign by index, never push
  4. no lock: no await mid-increment
  5. await Promise.all of the runners

basics

~20 s

Start exactly limit runner loops. Each pulls the next index from a shared cursor, awaits worker for that item, and writes the value into results[index] before pulling again. Awaiting all runners gives results in input order.

solid answer

~50 s

I allocate a results array of the same length, keep a shared `next` cursor at 0, and define a runner: a loop that takes `const i = next++`, awaits `worker(items[i])`, stores it as `results[i]`, and repeats until the cursor passes the end. Then I start `Math.min(limit, items.length)` runners and `await Promise.all(runners)` — the combinator here waits on a handful of long-lived loops, not on one promise per item, so at most `limit` worker calls exist at any moment. Order is preserved because each result is written by index rather than pushed on completion, so completion order never leaks into the output. No lock is needed around the cursor: `next++` is a synchronous read-modify-write and nothing else can interleave before the next `await`. A rejecting worker makes `Promise.all` reject while other runners are still mid-`await`, so I wrap the worker if I need every item attempted.

code

javascript · 26 lines
javascript
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function mapWithConcurrency(items, limit, worker) {
  const results = new Array(items.length);
  let next = 0;
  async function runner() {
    while (next < items.length) {
      const i = next++;
      results[i] = await worker(items[i], i);
    }
  }
  const runners = Array.from({ length: Math.min(limit, items.length) }, runner);
  await Promise.all(runners);
  return results;
}

(async () => {
  const finished = [];
  const out = await mapWithConcurrency([500, 100, 250, 50], 2, async (ms, i) => {
    await sleep(ms);
    finished.push(i);
    return ms;
  });
  console.log(out);      // [ 500, 100, 250, 50 ]  <- input order
  console.log(finished); // [ 1, 2, 3, 0 ]         <- completion order
})();

go deeper

for a junior

Know that the cap comes from starting a fixed number of loops that each take one item at a time. Remember to store each result at its own index so the returned array matches the input order.

for a middle

Be able to write the pool from memory and explain each part: the shared cursor, index assignment for ordering, Math.min for the runner count, and awaiting the runners rather than the items. Say why no lock is needed.

for a senior

Discuss failure semantics — that one rejection settles the aggregate while other runners are still mid-flight — and how you would either wind the pool down on first error or wrap each call so every item is attempted. Mention progress reporting and per-dependency limiters.

for a principal

Argue for where the limiter lives: a shared instance owned by the client layer rather than a bound re-implemented in every job, so the cap reflects the dependency's budget across all call sites. Be ready to justify building it versus adopting a dependency.

## The shape of the problem You want three things at once: never more than `limit` operations in flight, results in input order, and no promise created before there is a slot for it. The last point rules out `items.map(worker)` entirely — that starts everything. What you need is a small number of long-lived loops that each pull work. ## The pool ```js async function mapWithConcurrency(items, limit, worker) { const results = new Array(items.length); let next = 0; async function runner() { while (next < items.length) { const i = next++; results[i] = await worker(items[i], i); } } const runners = Array.from({ length: Math.min(limit, items.length) }, runner); await Promise.all(runners); return results; } ``` That is the whole thing. Walk an interviewer through it in four beats. **One cursor, many consumers.** `next` lives in the closure shared by every runner. Each iteration claims exactly one index. Because the claim and the increment happen in the same expression, two runners can never claim the same item. **Why no lock is needed.** `const i = next++` is a synchronous read-modify-write. JavaScript finishes the current job before running anything else, and there is no `await` between the read and the write, so no other runner can observe a stale `next`. This is the one place where single-threaded semantics genuinely simplify the code: the identical loop in a threaded language would need a mutex or an atomic counter. **Why order survives.** Results are assigned by index, never pushed. Item 0 may finish last; it still lands at position 0. If you wrote `results.push(await worker(...))` you would get completion order, which is a classic bug because it looks correct whenever the fixture happens to have uniform durations. **Why concurrency is exactly `limit`.** A runner has at most one outstanding `await` at a time, and there are `limit` runners. When one finishes it immediately claims the next index — there is no barrier, no waiting for peers, so the pool stays saturated until the queue empties. ## Edge cases worth naming - `Math.min(limit, items.length)` avoids spawning idle runners; without it a limit of 100 over 3 items creates 97 loops that exit on their first condition check. Harmless but sloppy. - Empty `items` produces zero runners, `Promise.all([])` fulfils, and you get `[]`. - `new Array(n)` gives a sparse array; every slot is written before you return, so the holes never escape. If that makes you uneasy, `new Array(n).fill(undefined)` costs nothing. - Passing the index through as `worker(items[i], i)` matters when the worker needs to label its output — and it is the reason to avoid `chunk.map(worker)` style shortcuts elsewhere, where the index would be chunk-local. ## Failure behaviour If `worker` rejects, the `await` inside that runner throws, the runner's promise rejects, and `Promise.all` rejects with the first error. The other runners are not cancelled — they are sitting on their own `await` and will complete their current item (and then keep pulling, since nothing told them to stop). If you want the pool to wind down on first failure, set a shared `failed` flag and check it in the loop condition. If you want every item attempted regardless, wrap the call so it cannot reject: ```js results[i] = await worker(items[i], i).then( (value) => ({ ok: true, value }), (error) => ({ ok: false, error }), ); ``` ## The semaphore variant A pool is the right shape when you own the loop. When calls are scattered across a codebase, you want a limiter object instead: a function that accepts a thunk, runs it if a slot is free, and otherwise queues it. Callers then write `limiter(() => fetchUser(id))` anywhere, and the cap is global across all call sites because they share one limiter instance. With that in hand, `await Promise.all(ids.map((id) => limiter(() => fetchUser(id))))` is correct and bounded — the `map` creates queue entries, not requests, and `Promise.all` still gives you input order for free. ## Bugs interviewers watch for - Recomputing the index as `items.indexOf(item)` — quadratic, and wrong with duplicate values. - Reading `next` and incrementing it on separate lines *across an await*, which reintroduces the double-claim. - Starting the runners inside a loop that awaits each one, which silently makes concurrency 1. - Building the promise array first and "limiting" afterwards, which limits nothing.

  • How would you report progress as items complete?
    Take an optional `onProgress` callback and invoke it right after each `results[i] = ...` assignment, passing a completed counter and the total. It fires from whichever runner finished, so treat it as unordered. Keep it synchronous and cheap — awaiting inside it would occupy a pool slot and quietly reduce your effective concurrency.
  • What happens with an empty items array, or a limit larger than the array?
    With `Math.min(limit, items.length)` an empty array spawns zero runners, `Promise.all([])` fulfils, and you return `[]`. A limit above the length spawns one runner per item, so the pool degenerates to the unbounded case — which is correct, since there is no more work to withhold.
  • Two unrelated modules both call the same downstream. How do you cap them together?
    A per-call-site pool cannot see the other's slots, so create one limiter in a shared module and have both sites route their calls through it. The bound then belongs to the dependency rather than to a loop, which is usually where you want it — a per-loop cap of 10 in two places is a cap of 20.

saying these in an interview costs you the question

  • Pushes results on completion, so output is in finish order
  • Creates all the promises first, then tries to limit them
  • Thinks the shared cursor needs a lock or an atomic
  • Awaits each runner in sequence, making concurrency one
  • Uses indexOf to recover the item's position

context