Skip to content

Kata: promise pool (concurrency limit)

Functions in, a checked limit, claim with next++ before the await, write by index, and stop claiming at the first failure.

Predict first

Before you read: predict

EXH-2790. ExamHall's bulk regrade re-scores finished attempts through the scoring API a few at a time, so the API's pool of 20 connections is never flooded. Its pool was written in a 25-minute kata:

On the virtual clock each scoring call answers 30 after it starts, and none fails.

Which calls does the scoring API receive?

Code for this question
async function runPool(tasks, limit) {  const results = new Array(tasks.length);  let next = 0;  async function worker() {    while (next < tasks.length) {      const i = next;      results[i] = await tasks[i]();      next += 1;    }  }  await Promise.all(Array.from({ length: limit }, worker));  return results;} const tasks = ['a-101', 'a-102', 'a-103', 'a-104'].map((id) => () => scoreAttempt(id));runPool(tasks, 2).then((scores) => saveScores(scores));
Pick one

The 30-second version

A pool takes task functions, checks its limit, and starts Math.min(limit, n) workers. Each worker takes an index with next++ in the same step that reads it, before awaiting, and writes the result there. On the first failure a shared flag stops further claims and the error is rethrown; running tasks finish. Collecting every error stores each one instead.

Functions in, a checked limit, claim with next++ before the await, write by index, and stop claiming at the first failure.

Step through the code

Trace of the opening question

Play options

Ready: press Play or step with Next frame

Row 1 of 4 · t 0

Starting row · computed by comparing each row with the one before

  • t: 0
  • Worker 1: reads 0, calls a-101
  • Worker 2: reads 0, calls a-101
  • next after: 0
  • Calls so far: 2

Rule · step 3

Claim in one step. const i = next++ reads and takes the index together; reading now and adding 1 after the await lets workers share an index.

t
0
Worker 1
reads 0, calls a-101
Worker 2
reads 0, calls a-101
next after
0
Calls so far
2
Step
3
Trace of the opening question
tWorker 1Worker 2next afterCalls so farStep
0reads 0, calls a-101reads 0, calls a-101023
30 (changed)adds 1, reads 1, calls a-102 (changed)adds 1, reads 2, calls a-103 (changed)2 (changed)4 (changed)3
60 (changed)adds 1, reads 3, calls a-104 (changed)adds 1, reads 4, ends (changed)4 (changed)5 (changed)2, 3
90 (changed)adds 1, reads 5, ends (changed)— (changed)5 (changed)55

Space plays or pauses · ←/→ one row · Home/End first/last

Rows come from the pack's own trace table.

The probe code (rows are not linked to code lines)
The probe code
async function runPool(tasks, limit) {  const results = new Array(tasks.length);  let next = 0;  async function worker() {    while (next < tasks.length) {      const i = next;      results[i] = await tasks[i]();      next += 1;    }  }  await Promise.all(Array.from({ length: limit }, worker));  return results;} const tasks = ['a-101', 'a-102', 'a-103', 'a-104'].map((id) => () => scoreAttempt(id));runPool(tasks, 2).then((scores) => saveScores(scores));

How it works

Step 1 of 9

Check the limit (whole, above 0). Math.min(NaN, n) is NaN, and a NaN or 0 length starts no worker: the pool resolves at once.
See it run

Not in this run

In the kitchen
  • three boards play at once; with no board set up, the caller still marks the round "complete" though nobody threw → the limit, checked first (step 1)

Analogy: A darts open's match caller and its boards

At the darts open In the code
three boards play at once; with no board set up, the caller still marks the round "complete" though nobody threw the limit, checked first (step 1)
a free board's marshal crosses the next match off the draw sheet as she reads it next++ (step 3)
a marshal who reads the next open match and crosses it off only when her own match ends sends a second board the same pairing a read now, an increment later (step 3)
each result is written beside its match number on the sheet results[i] (step 4)
a tampered scoring unit: the director stops calling matches, and matches already on the oche are played out the failed flag (step 6)
on a league night each walkover is noted against its match, and calling goes on collect every error (step 7)

In real terms: a shared counter hands out indexes, a worker takes one only by moving the counter, and one flag decides whether anyone takes another.

Breaks down when: the director halts a match mid-leg. A pool cannot stop a running task; that needs an AbortSignal the task checks.

Contrasting case: a caller who sends three matches and waits for all three boards to finish before calling the next three: fixed batches, the prerequisite's slower pattern.

Examples, run for real

4.1 ExamHall MR !3090: the reference, on the hook's numbers

MR !3090 fixed EXH-2790 with the reference (kata/promise-pool.mjs): next++ claims, Math.min(limit, n) workers, a checked limit and a failed flag. The curriculum's run is 2,400 attempts with a limit of 8, against the API's pool of 20 connections. Then the same run where one attempt's scoring fails:

4.1 2,400 attempts, limit 8: scoring calls 2,400, each attempt once · most calls open at once 8 (the API's pool: 20) · scores in attempt order: yes
4.1 attempt 1,000's scoring fails: the regrade rejects with "a-1000: scoring 500" · calls started after the failure 0 · calls still open at the failure 7, all of which finished · attempts never scored 1,394

Every attempt is scored once, in order, with never more than 8 calls open (steps 2–4). After the failure no worker claims again, and the 7 calls already open still finish, because nothing cancels them (step 6).

4.2 Proof domain, media streaming: the test list, run

A streaming team's reviewer asks what "done" means for the kata. The answer is the nine-test file, run against the reference and against p-limit 7.3.3, the curriculum row's library, through its limit.map:

4.2 node --test kata/promise-pool.test.mjs (reference): 9 tests · pass 9 · fail 0
4.2 KATA_IMPL=p-limit node --test kata/promise-pool.test.mjs (p-limit 7.3.3): 9 tests · pass 7 · fail 2 (rejects with the first failure and starts no task after it; treats a task that throws before returning a promise as a failure)

Each test is one item of step 9. p-limit passes the seven that do not depend on the failure policy and fails the two that do: after a failure it keeps starting queued tasks. The policy is a choice, so ask which one the interviewer wants before coding (steps 6–7).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 7

A streaming service renders a film's chapter thumbnails with this pool:

On the new worker host, THUMB_PARALLEL is not set. Five frames are queued, and each thumbnail renders in 20 on the virtual clock.

What does the job report, and when?

Code for this question
async function renderAll(tasks, limit) {  const results = new Array(tasks.length);  let next = 0;  async function worker() {    while (next < tasks.length) {      const i = next++;      results[i] = await tasks[i]();    }  }  await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));  return results;} const limit = Number(process.env.THUMB_PARALLEL);renderAll(frames.map((frame) => () => renderThumb(frame)), limit).then(  (thumbs) => report(`${thumbs.filter(Boolean).length} of ${thumbs.length} thumbnails`),  (error) => report(`failed: ${error.name}`),);
Pick one

Question 2 of 7

A streaming service transcodes each upload into six renditions, two at a time:

The renditions are 1080p, 720p, 480p, 360p, 240p and 144p, in that order. In one upload the 480p encode fails 10 after it starts: the upload is marked failed at t=20, yet the encoder log shows 360p, 240p and 144p starting at t=40, 60 and 70. Each change below also declares let failed = false; beside next and guards the loop with while (!failed && next < jobs.length).

Which one still marks the upload failed at t=20 and starts no rendition after the failure, while a clean upload still publishes all six as it does today?

Code for this question
async function transcodeAll(jobs, limit) {  if (!Number.isInteger(limit) || limit < 1) throw new RangeError('limit');  const results = new Array(jobs.length);  let next = 0;  async function worker() {    while (next < jobs.length) {      const i = next++;      results[i] = await jobs[i]();    }  }  await Promise.all(Array.from({ length: Math.min(limit, jobs.length) }, worker));  return results;} transcodeAll(renditions.map((r) => () => encode(r)), 2).then(publish, markUploadFailed);
Pick one

Question 3 of 7

A streaming catalogue refreshes title artwork with the stop-at-the-first-failure pool:

Tonight's titles are Arrival, Borgen, Casablanca and Dark, two at a time, and the artwork of Borgen and Casablanca fails. The report lists Borgen's failure only, and Casablanca and Dark are never tried. The product team now wants every title tried and one report that lists every failure.

Which one change does that?

Code for this question
async function refreshAll(tasks, limit) {  if (!Number.isInteger(limit) || limit < 1) throw new RangeError('limit');  const results = new Array(tasks.length);  let next = 0;  let failed = false;  async function worker() {    while (!failed && next < tasks.length) {      const i = next++;      try {        results[i] = await tasks[i]();      } catch (error) {        failed = true;        throw error;      }    }  }  await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));  return results;} refreshAll(titles.map((title) => () => fetchArtwork(title)), 2).then(  (done) => report(done.filter((r) => r instanceof Error)),  (error) => report([error]),);
Pick one

Question 4 of 7

EXH-2791. ExamHall's nightly export uploads each candidate's transcript with a pool written in a kata:

Tonight's candidates are c-1, c-2, c-3, c-4 and c-5, in that order, two at a time. c-2 has no transcript: calling uploadTranscript('c-2') throws a TypeError at once, and nothing is sent for it. Every other upload answers 10 after it starts.

What does the export report, and which uploads start after the report?

Code for this question
async function exportAll(tasks, limit) {  if (!Number.isInteger(limit) || limit < 1) throw new RangeError('limit');  const results = new Array(tasks.length);  let next = 0;  let failed = false;  async function worker() {    while (!failed && next < tasks.length) {      const i = next++;      const upload = tasks[i]();      try {        results[i] = await upload;      } catch (error) {        failed = true;        throw error;      }    }  }  await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));  return results;} exportAll(candidates.map((id) => () => uploadTranscript(id)), 2).then(  () => report('export done'),  (error) => report(`export failed: ${error.message}`),);
Pick one

Question 5 of 7

25 minutes. In a blank file, write promisePool(tasks, limit), where tasks is an array of functions that each return a promise, as you would for ExamHall's bulk regrade: at most limit calls in flight, results in task order, and the first failure rejects and starts nothing new. Say your test list out loud before the code.

Which nine tests do you name before you start, and what code passes them?

Question 6 of 7

A ticket site holds a group's seats with this pool:

A group asks for seats A1, A2, Z0 and A4. There is no seat Z0, so holdSeat('Z0') throws a TypeError as soon as it is called, before it sends anything; A1's hold answers at t=10, A2's at t=20, and A4's 10 after it is sent. The basket spinner never stops, A4's hold is sent at t=20, and Node reports an unhandled TypeError.

Which line holds the bug?

Code for this question
function holdAll(tasks, limit) {  return new Promise((resolve, reject) => {    if (!Number.isInteger(limit) || limit < 1) return reject(new RangeError('limit'));    const results = new Array(tasks.length);    let next = 0;    let done = 0;    let failed = false;    if (tasks.length === 0) return resolve(results);    const launch = () => {      const i = next++;      tasks[i]().then((value) => {        results[i] = value;        done += 1;        if (done === tasks.length) resolve(results);        else if (!failed && next < tasks.length) launch();      }, (error) => {        failed = true;        reject(error);      });    };    for (let w = 0; w < Math.min(limit, tasks.length); w++) launch();  });} holdAll(seats.map((seat) => () => holdSeat(seat)), 2).then(showBasket, showError);
Pick one

Question 7 of 7

A payments service refunds the day's cancelled orders with this pool, four at a time:

On a day with three cancelled orders, each refund answers 10 after it is sent. All three refunds are sent at t=0, yet the batch is flagged at t=0 with a TypeError and no refund is posted.

Which one change posts the three refunds at t=10, refunds each order once, and keeps the kata's nine tests passing?

Code for this question
function refundAll(tasks, limit) {  return new Promise((resolve, reject) => {    if (!Number.isInteger(limit) || limit < 1) return reject(new RangeError('limit'));    const results = new Array(tasks.length);    let next = 0;    let done = 0;    let failed = false;    if (tasks.length === 0) return resolve(results);    const launch = () => {      const i = next++;      Promise.resolve().then(() => tasks[i]()).then((value) => {        results[i] = value;        done += 1;        if (done === tasks.length) resolve(results);        else if (!failed && next < tasks.length) launch();      }, (error) => {        failed = true;        reject(error);      });    };    for (let w = 0; w < limit; w++) launch();  });} refundAll(orders.map((order) => () => refund(order)), 4).then(postRefunds, flagBatch);
Pick one

Go deeper

Pro tips
  1. Pass functions, not promises, and say so before coding. When: the interviewer's signature takes "tasks" (step 1). If the input is already promises, every call has started and no limit is possible; wrapping a started promise as () => p changes nothing. Verify (RAN, tips.mjs):
    tip 1 six tasks, limit 2, passed as functions: at most 2 running at once
    tip 1 the same tasks started first and wrapped as () => p: at most 6 running at once
Source

MDN, the p-limit README and the interview handbook change between releases, so their claims are labelled as of 2026-10-04.

Checked against the tools and docs as of 2026-10-04.