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?
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));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
| t | Worker 1 | Worker 2 | next after | Calls so far | Step |
|---|---|---|---|---|---|
| 0 | reads 0, calls a-101 | reads 0, calls a-101 | 0 | 2 | 3 |
| 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) | 5 | 5 |
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)
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
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?
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}`),);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?
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);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?
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]),);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?
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}`),);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?
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);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?
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);Go deeper
Pro tips
- 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
() => pchanges 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.
- Front End Interview Handbook, JavaScript utility functions: https://www.frontendinterviewhandbook.com/coding/javascript-utility-function (
feih-utility-functions.txt), as of 2026-10-04. It lists "Lodash's chunk()/map() with follow up of mapAsync(Promise.all) then mapWithChunksAsync", advises "implementing them yourself and writing test cases for them", and says "Advanced questions are usually given to more senior candidates and expect around 25-30 minutes to complete or arrive at a minimally working solution." - p-limit README (sindresorhus/p-limit, 7.3.3 installed): https://github.com/sindresorhus/p-limit (
p-limit-readme.txt, the rendered README), as of 2026-10-04. "Run multiple promise-returning & async functions with limited concurrency"; forconcurrency, "Minimum: 1"; onclearQueue(), "Note: This does not cancel promises that are already running." - MDN, Promise.all(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all (
mdn-promise-all.txt), as of 2026-10-04. "It rejects when any of the input's promises rejects, with this first rejection reason." Supports steps 5 and 6. - MDN, Math.min(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/min (
mdn-math-min.txt, fetched on 2026-10-04 in the R2 fix), as of 2026-10-04. "Returns NaN if any of the parameters is or is converted into NaN." Supports step 1.
Related lessons
Checked against the tools and docs as of 2026-10-04.