Skip to content

Promise combinators and concurrency limits

Combinators decide when you hear back, not what keeps running. Cancelling needs an AbortSignal, and a concurrency cap needs a pool.

Predict first

Before you read: predict

EXH-362 "One failed recording chunk fails the whole submit (Promise.all over 12 uploads)". Here is the shape of the fix, MR !652, cut down to 4 chunks and a limit of 2. Chunk 2 is rejected at t=100. The clock is virtual, so no real time passes.

Which lines print, and in what order?

Code for this question
function limit(max) {                    // at most `max` tasks started and not yet settled  let active = 0;  const queue = [];  const next = () => {    if (active >= max || queue.length === 0) return;    active++;    const { task, resolve, reject } = queue.shift();    task().then(resolve, reject).finally(() => { active--; next(); });  };  return (task) => new Promise((resolve, reject) => { queue.push({ task, resolve, reject }); next(); });} const plan = { 1: [50, 'ok'], 2: [100, 'fail'], 3: [200, 'ok'], 4: [300, 'ok'] };async function upload(n) {  const [ms, outcome] = plan[n];  await sleep(ms);  if (outcome === 'fail') throw new Error(`chunk ${n} rejected (413)`);  log(`chunk ${n} stored`);  return n;} const run2 = limit(2);Promise.allSettled([1, 2, 3, 4].map((n) => run2(() => upload(n)))).then(  (rs) => log(`retry chunks [${rs.flatMap((r, i) => (r.status === 'rejected' ? [i + 1] : [])).join(', ')}]`),  (e) => log(`submit failed: ${e.message}`),);
Pick one

The 30-second version

A combinator watches already-running promises. all rejects at the first rejection. allSettled never rejects and reports every outcome. race copies the first input to settle. any takes the first fulfilment, or rejects with an AggregateError. None of them cancels or limits anything. A pool limits: it holds functions, not promises, and starts the next whenever one settles.

Combinators decide when you hear back, not what keeps running. Cancelling needs an AbortSignal, and a concurrency cap needs a pool.

How it works

Step 1 of 7

Calling starts the work. chunks.map(upload) starts every upload before any combinator runs; the combinator only watches running promises.
In the kitchen
  • Each precinct starts counting its own ballots the moment polls close; the centre only receives returns → Calling upload(n) starts the work; the combinator only watches (step 1)

Analogy: Election night at a county counting centre

At the counting centre In the system
Each precinct starts counting its own ballots the moment polls close; the centre only receives returns Calling upload(n) starts the work; the combinator only watches (step 1)
Certification needs a valid return from every precinct; the first rejected return stops certification at once, without waiting for the rest all rejects at the first rejection (step 3)
The public report lists every precinct in precinct-number order, with its total or the reason its return was rejected allSettled: every outcome, in input order (step 4)
The news desk calls the first precinct to report anything, even a rejected return; the analyst waits for the first valid return, and if all are rejected, files the rejections in precinct-number order race versus any and AggregateError.errors (step 5)
A failed certification sends no one home; every precinct keeps counting settling cancels nothing (step 6)
The centre has 4 verification teams. A precinct waiting for review gets the next team to finish, whether that team accepted or rejected its last return the pool keeps k in flight and refills a slot on every settle (step 7)
  • Breaks down when: a real election board can order a precinct to stop counting. A promise has no such order, and only work that checks an AbortSignal stops. It also breaks down on a recount: a precinct can report twice, but a promise settles exactly once.
  • Contrasting case: a court order that halts every count in the county at the same moment. That is cancellation (AbortController.abort() stopping every fetch that shares the signal), and no combinator ever does it.
  • In real terms: a combinator is a new promise whose settle rule reads its inputs' outcomes. It never controls the work behind them.

Examples, run for real

Example 1: ExamHall, the MR !652 fix for EXH-362. Submit uploads 12 recording chunks, and chunks 5 and 9 fail with 503. The same uploads run three ways on the virtual clock.

const CHUNKS = Array.from({ length: 12 }, (_, i) => i + 1);
const FAILS = new Set([5, 9]);
const durationOf = (n) => 100 + (n % 4) * 50;

function limit(max) {                        // the pool: at most `max` started and unsettled
  let active = 0;
  const queue = [];
  const next = () => {
    if (active >= max || queue.length === 0) return;
    active++;
    const { task, resolve, reject } = queue.shift();
    task().then(resolve, reject).finally(() => { active--; next(); });
  };
  return (task) => new Promise((resolve, reject) => { queue.push({ task, resolve, reject }); next(); });
}
// upload(n): counts in-flight, sleeps durationOf(n), throws `chunk n: 503` for 5 and 9
const report = (rs) => {
  const failed = rs.flatMap((r, i) => (r.status === 'rejected' ? [CHUNKS[i]] : []));
  return `stored ${rs.length - failed.length}/12, retry chunks [${failed.join(', ')}]`;
};

await scenario('all, unbounded',        (upload) => Promise.all(CHUNKS.map(upload)).then(() => 'all stored'));
await scenario('allSettled, unbounded', (upload) => Promise.allSettled(CHUNKS.map(upload)).then(report));
const run3 = limit(3);
await scenario('allSettled, pool of 3', (upload) => Promise.allSettled(CHUNKS.map((n) => run3(() => upload(n)))).then(report));
[all, unbounded] t=150 submit rejected: chunk 5: 503; uploads still running: 7
[all, unbounded] clock drained at t=250; peak in flight 12; uploads finished 12/12
[allSettled, unbounded] t=250 stored 10/12, retry chunks [5, 9]; uploads still running: 0
[allSettled, unbounded] clock drained at t=250; peak in flight 12; uploads finished 12/12
[allSettled, pool of 3] t=750 stored 10/12, retry chunks [5, 9]; uploads still running: 0
[allSettled, pool of 3] clock drained at t=750; peak in flight 3; uploads finished 12/12

Line 1 shows steps 3 and 6: the submit reports failure while 7 uploads are still running, and all 12 finish anyway. Lines 3–6 show steps 4 and 7: allSettled names exactly which chunks to retry, and only the pool keeps the peak at 3. The pool trades time for load: 750 virtual ms against 250.

Each replica sleeps, logs, then returns or throws <name>: 504.

const mixed = { eu: [20, false], us: [60, true], ap: [90, true] };
await scenario('A. race, eu fails first', mixed, (ps) => Promise.race(ps));
await scenario('B. any, same replicas', mixed, (ps) => Promise.any(ps));
await scenario('C. any, all three fail', { eu: [80, false], us: [20, false], ap: [50, false] }, (ps) => Promise.any(ps));
A. race, eu fails first
t=  20    eu failed
t=  20    -> rejected: Error: eu: 504
t=  60    us answered
t=  90    ap answered
B. any, same replicas
t=  20    eu failed
t=  60    us answered
t=  60    -> fulfilled: us price 49.90
t=  90    ap answered
C. any, all three fail
t=  20    us failed
t=  50    ap failed
t=  80    eu failed
t=  80    -> rejected: AggregateError: All promises were rejected | errors: [eu: 504; us: 504; ap: 504]

A and B use the same inputs but settle differently: race takes the first settle and any takes the first success (step 5). In C the replicas fail in the order us, ap, eu, but errors lists them eu, us, ap, which is input order. The message text is V8's; MDN's own example prints a different message, so match on name, never on the message. Both ap answers in A and B still arrive after the decision (step 6).

Picture it

a crossroads of four paths with a checkpoint post at the centre. A guide stands by a table that holds three colored tokens and holds out a fourth. Two hikers (upper left, upper right) walk toward the centre. The two in front walk away from it, backs turned, toward the lower corners. Read each path as an input promise and the checkpoint as the combinator's settle rule. Two hikers leaving is the nearest the image comes to "some inputs have already settled".
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 6

(p1-ticketing-race.mjs). 60 s.

Which lines print, in order?

Code for this question
async function holdSeat(seat) { await sleep(120); log(`seat ${seat} held`); return seat; }const timeout = (ms) => sleep(ms).then(() => { throw new Error('hold timed out'); });Promise.race([holdSeat('A12'), timeout(100)]).then(  (seat) => log(`checkout continues with ${seat}`),  (e) => log(`checkout error: ${e.message}`));

Question 2 of 6

(p2-payments-any.mjs). 45 s.

What does this print?

Code for this question
const processors = [['stripe-eu', 90], ['adyen', 30], ['backup', 60]];const charge = async ([name, ms]) => { await sleep(ms); throw new Error(`${name} declined`); };Promise.any(processors.map(charge)).catch((e) => {  console.log(e.name);  console.log(e.errors.map((x) => x.message).join(', '));});

Question 3 of 6

30 s. "A message has 5 attachments. The UI must show a Retry button on each file that failed and keep the ones that uploaded.

Which combinator?

Pick one

Question 4 of 6

. 60 s. Served first on the FAST and TARGETED branches; last in FULL practice. It checks the prerequisite's A5 and A7 as they carry over to this concept (steps 3 and 6).

Which lines print, and in what order?

Code for this question
const plan = { 1: [50, 'ok'], 2: [100, 'fail'], 3: [200, 'ok'], 4: [300, 'ok'] };async function upload(n) {  const [ms, outcome] = plan[n];  await sleep(ms);  if (outcome === 'fail') throw new Error(`chunk ${n} rejected (413)`);  log(`chunk ${n} stored`);  return n;} Promise.all([1, 2, 3, 4].map(upload)).then(  (r) => log(`submitted ${r.length} chunks`),  (e) => log(`submit failed: ${e.message}`),);

Question 5 of 6

A chat app exports 3 conversations. Each has 3 attachments, and exportConv(c) runs Promise.all(c.files.map(download)). The page runs Promise.allSettled(convs.map((c) => run2(() => exportConv(c)))), where run2 = limit(2) is the pool from this lesson. The CDN allows at most 2 downloads in flight, but up to 8 are seen. Attachment b2 of conversation c2 fails with 404.

Which one change keeps downloads at 2 or fewer and still reports that c2 failed?

Pick one

Question 6 of 6

A streaming service's analytics dashboard has 3 panels. Each panel loads its 2 queries with Promise.allSettled(queries.map(runQuery)), and the page runs Promise.all(panels).then(render, showBanner). Two of the 6 queries fail with 500, yet the red banner never appears: the page renders 3 panels at t = 70.

Which is the bug?

Pick one

Go deeper

Pro tips
  1. Test a concurrency cap with node:test mock timers, not real waits. Call mock.timers.enable({ apis: ['setTimeout'] }), then tick(100) and yield with setImmediate (which is not mocked) between ticks. Assert the peak in-flight count. When it helps: step 7. A pool that leaks a fifth upload fails this test, while a test with real timers stays green and slow. Verify: node --test --test-reporter=dot tip1-pool.test.mjs prints . and exits 0 (50/50). The mutant limit(4) fails with actual: 4, expected: 3. Status RAN · Node 24.19.0.
  2. Make a timeout actually cancel: fetch(url, { signal: AbortSignal.any([userSignal, AbortSignal.timeout(ms)]) }) instead of Promise.race([fetch(url), timer]). When it helps: step 6. With race, the losing request keeps its socket. Verify: node tip2-abort-fetch.mjs against a local server that answers after 2 s prints client: fetch rejected with TimeoutError and server: request closed by client, response sent: false (50/50). Status RAN · Node 24.19.0 · real time, with a 50 ms vs 2,000 ms margin, so it is never graded.
Source
Source Supports Checked
MDN, Promise.allSettled(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled step 4 (never rejects; one outcome object per input) fetched 2026-09-27
MDN, Promise.any(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/any step 5 (AggregateError; "errors are in the order of the promises passed, regardless of completion order") fetched 2026-09-27

Valid as of 2026-09-27 · Node 24.19.0. The curriculum also lists MDN Promise.all, which was not fetched this session.

Checked against the tools and docs as of 2026-09-27.