Engrain by ByteRush

JS.11.07 Senior· 10 minCore — asked often

Concurrency control: run many async tasks, but not all at once

Promise.all on 500 uploads gets you rate-limited; one at a time is too slow. Build a promise pool that runs N at a time and keeps results in order.

Comes up in: Coding, Backend design

Learn#

Ravi’s gallery app uploads 500 photos with Promise.all(photos.map(upload)). It works on his laptop. In production the storage API answers 429 Too Many Requests, and phones on slow networks time out. Uploading one at a time fixes the errors but takes ten minutes.

The expert move is in between: limited concurrency — at most N tasks in flight, starting a new one whenever one finishes.

A promise pool does this with N “workers”. Each worker loops: take the next task, await it, repeat.

The trick that keeps results in order: a worker claims an index before awaiting, and writes its result into that slot.

async function promisePool(tasks, limit) {
  const results = new Array(tasks.length);
  let next = 0;
  async function worker() {
    while (next < tasks.length) {
      const index = next++; // claim before any await
      results[index] = await tasks[index]();
    }
  }
  await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
  return results;
}

let running = 0, most = 0;
const upload = (name, ms) => () => new Promise((resolve) => {
  running++; most = Math.max(most, running);
  setTimeout(() => { running--; console.log("finished", name); resolve(name); }, ms);
});

const tasks = [upload("a.jpg", 80), upload("b.jpg", 10), upload("c.jpg", 20), upload("d.jpg", 10), upload("e.jpg", 10)];
console.log(await promisePool(tasks, 2), "| most at once:", most);

Output (verified)

finished b.jpg
finished c.jpg
finished d.jpg
finished e.jpg
finished a.jpg
[ 'a.jpg', 'b.jpg', 'c.jpg', 'd.jpg', 'e.jpg' ] | most at once: 2
Two workers, five uploadsStep 1 of 5

State

  • next = 2
  • running: a (80 ms), b (10 ms)

Console

    empty

    Five uploads, limit 2. Two workers start; each claims the next task index before awaiting.

    Read the steps as text
    1. Five uploads, limit 2. Two workers start; each claims the next task index before awaiting.
    2. At 10 ms, b finishes. Its worker stores the result in slot 1 and claims task c.
    3. At 30 ms, c finishes. The same worker claims d. Task a is still running in the other worker.
    4. d finishes at 40 ms and e at 50 ms. Now there are no tasks left, so this worker stops.
    5. At 80 ms, a finishes last, into slot 0. Finish order was b, c, d, e, a — the slots kept the input order.

    Predict#

    Predict first

    A teammate writes promisePool(photos.map(upload), 3). What happens?

    Interview angle#

    Say it like this — about 30 seconds

    “I’d run a promise pool. The tasks are functions, so nothing starts early. I start N workers; each one loops: claim the next index, await that task, store the result at that index, repeat until the list is empty. Claiming the index before awaiting keeps results in input order even though tasks finish out of order. I await all workers, and if any task rejects, the pool rejects.”

    Stays on your device. Nothing is uploaded.
    Follow-up How would you stop on the first error?
    Reject immediately and set a stopped flag the workers check before claiming more; optionally pass an AbortSignal to in-flight tasks.
    Follow-up How would you collect all results, including failures?
    Store { status, value | reason } per slot, like Promise.allSettled.
    Follow-up How is this different from batching?
    Batching groups many small calls into one request (DataLoader-style); a pool limits how many independent requests run at once. They combine well.

    Practice#

    Timed, like the real round

    Implement promisePool(tasks, limit) in 20 minutes. Results must come back in input order.

    Done when

    • Never more than limit tasks run at once (checked with uneven durations).
    • Results are in input order; an empty list resolves to [].
    • The first rejection rejects the pool, and no task starts twice.
    Your solution
    Ctrl/⌘ + Enter

    Remember#

    Try to answer each card before you open it. Then add them to your reviews.

    Why does a promise pool take task functions instead of promises?

    Promises start when created, so the pool must create them itself to limit how many run.

    How does a promise pool keep results in input order?

    Each worker claims an index before awaiting and writes its result to that slot.

    Why are fixed chunks of Promise.all slower than a pool?

    Each chunk waits for its slowest task, so free slots sit idle.

    Write promisePool(tasks, limit) from memory.

    Write it from memory, then run it against the tests in Practice.

    Go deeper

    Four layers. Stop at the one your interview needs; each ends with a quick check.

    The core ideaEvery round

    Three shapes: sequential (one at a time — safe, slow), all at once (fast until something pushes back), limited (N in flight, a new one starts when one ends). Choose N from the slowest constraint: the API’s rate limit, the server’s capacity or the user’s network.

    Check yourself: What does a concurrency limit of 3 guarantee?

    No more than 3 tasks are in flight at any moment.

    How it works underneathSenior rounds

    JavaScript runs one thing at a time, so const index = next++ cannot be interrupted: two workers can never claim the same index. They only take turns at await. Promise.all over the workers resolves when every worker has run out of tasks Source: ECMA-262: Promise.all.

    Check yourself: Why is `next++` safe without locks here?

    JavaScript runs one piece of code at a time, and the increment happens before any await.

    Edge cases and trapsWhere people slip

    limit larger than the list: start only as many workers as tasks. limit 0 or negative: reject early. Tasks that throw synchronously (not returning a rejected promise) still reject the worker’s await. After the first rejection, other workers keep running unless you stop them.

    Check yourself: What happens with limit 0?

    No worker starts, so the pool never makes progress; validate limit ≥ 1.

    In productionStaff-level answers

    Real systems combine a pool with retries and backoff (JS.11.06), respect Retry-After on 429s, and expose progress to the UI. Browsers already cap parallel connections per host on older HTTP versions, and newer versions send many requests over one connection — so a client-side limit mostly protects servers and rate limits, not the browser. For huge inputs, feed the pool from an async iterator with for await instead of building every task first Source: MDN: for await...of.

    Check yourself: Name one signal to adapt the limit at runtime.

    429 responses or a Retry-After header (back off), or rising latency.

    Finished the five steps? Mark it, and its cards join your reviews.

    Last checked 2026-10-31. Spot a mistake? Use “Report an error” below — fixes are listed on the corrections page.

    Interview questions this answers

    • How would you implement an async queue?
    • Run 100 async tasks with at most 5 at a time, keeping results in order.