A promise pool runs many asynchronous tasks but caps how many are in flight at once. Promise.all fires everything simultaneously — fine for three requests, a problem for three thousand, where you'd exhaust connections or hit rate limits. A pool keeps a fixed number running and starts the next task only when a slot frees up.
Implement promisePool(taskFns, concurrency). taskFns is an array of functions, each returning a promise. Run at most concurrency at a time, and resolve to an array of results in the same order as taskFns — regardless of the order they finish.
function promisePool(taskFns, concurrency) {
// taskFns: (() => Promise<T>)[]
// returns Promise<T[]> — results in taskFns order
}
const tasks = [
() => fetch('/a'), () => fetch('/b'),
() => fetch('/c'), () => fetch('/d'),
];
await promisePool(tasks, 2); // at most 2 requests in flight; results in [a,b,c,d] order
// Order is preserved even though task 2 finishes first:
await promisePool([() => delay(30, 'a'), () => delay(5, 'b')], 2); // ['a', 'b']
taskFns holds functions. A task doesn't start until you call its function, which is how you control when it begins.concurrency tasks running; start the next only when one settles.taskFns order, not completion order (like Promise.all).taskFns resolves to []; concurrency larger than the task count just runs them all.You'll run a batch of async tasks with a fixed number of "workers" — each worker grabs the next task as soon as it finishes the last, and you collect the results in order.
Promise.all(tasks.map(fn => fn())) starts everything at once. That's fine for a few tasks, but if you have hundreds of API calls, firing them all together will blow through rate limits, exhaust sockets, or spike memory. A pool fixes a ceiling: run concurrency tasks, and each time one finishes, start the next unstarted one. The number in flight stays flat. You're building promisePool(taskFns, concurrency).
Picture concurrency workers. Each worker, in a loop, takes the next task off a shared queue, runs it, stores the result at that task's index, and then reaches for the next task — until the queue is empty. When all tasks are done, resolve with the results array. The key subtlety: results go into a fixed slot (results[i]), so the order is by task position, not by who finished first.
The obvious version ignores the cap entirely:
function promisePoolNaive(taskFns) {
return Promise.all(taskFns.map((fn) => fn()));
}
This gets the ordering right (that's Promise.all's job) but does no throttling — taskFns.map(fn => fn()) calls every factory immediately, so all tasks start at once. The whole point of a pool is the concurrency limit, and this has none. We need to start only concurrency tasks, and launch each subsequent one from a completion handler.
function promisePool(taskFns, concurrency) {
return new Promise((resolve, reject) => {
const results = new Array(taskFns.length);
let nextIndex = 0; // the next task to hand out
let completed = 0; // how many have finished
if (taskFns.length === 0) return resolve([]);
const startNext = () => {
if (nextIndex >= taskFns.length) return; // queue empty
const i = nextIndex++;
// Call the factory now (starts the task); Promise.resolve normalizes
// a non-promise return. Store the result at its OWN index.
Promise.resolve()
.then(() => taskFns[i]())
.then((value) => {
results[i] = value;
if (++completed === taskFns.length) resolve(results);
else startNext(); // a slot freed up — pull the next task
})
.catch(reject); // fail fast on the first rejection
};
// Prime the pool with `concurrency` workers (capped at the task count).
const workers = Math.min(concurrency, taskFns.length);
for (let i = 0; i < workers; i++) startNext();
});
}
module.exports = { promisePool };
The pool is primed with min(concurrency, taskFns.length) calls to startNext, so exactly that many tasks begin. Each task's completion handler does two things: it stores its result at results[i] — the task's own index, which is what preserves order — and then calls startNext() to pull the next queued task into the freed slot. nextIndex is the shared cursor into the queue, so no two workers grab the same task. When completed reaches the total, every slot has been recycled through every task, and we resolve. The .catch(reject) makes it fail fast, matching Promise.all.
Take promisePool([A, B, C], 2) where the tasks take 30ms, 5ms, 15ms:
workers = min(2, 3) = 2. startNext() runs twice: task A starts (i = 0), task B starts (i = 1). nextIndex is now 2. C waits.B resolves first. results[1] = B's value; completed = 1. A slot freed → startNext() starts C (i = 2). Now A and C are running.C resolves. results[2] = C; completed = 2. startNext() finds nextIndex >= 3 → nothing to start.A resolves. results[0] = A; completed = 3 → resolve results.At no point were more than two tasks live. B finished first but landed in results[1], so the final array is [A, B, C] — task order, not finish order.
Promise objects, they've already started; the pool can't throttle what's already running. Pass functions and call them yourself.results[i] so the output matches taskFns order.concurrency workers, then let each recycle.concurrency workers when there are fewer tasks would call startNext past the end; cap the prime at taskFns.length (or guard inside startNext).allSettled-style pool keeps going after a rejection, recording { status, value/reason } per task. Swap the catch(reject) for storing the error and continuing.p-limit / Promise.map — libraries generalize this with per-call limiters and streaming results; the core idea is exactly this "prime N, refill on settle" loop.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A promise pool runs many asynchronous tasks but caps how many are in flight at once. Promise.all fires everything simultaneously — fine for three requests, a problem for three thousand, where you'd exhaust connections or hit rate limits. A pool keeps a fixed number running and starts the next task only when a slot frees up.
Implement promisePool(taskFns, concurrency). taskFns is an array of functions, each returning a promise. Run at most concurrency at a time, and resolve to an array of results in the same order as taskFns — regardless of the order they finish.
function promisePool(taskFns, concurrency) {
// taskFns: (() => Promise<T>)[]
// returns Promise<T[]> — results in taskFns order
}
const tasks = [
() => fetch('/a'), () => fetch('/b'),
() => fetch('/c'), () => fetch('/d'),
];
await promisePool(tasks, 2); // at most 2 requests in flight; results in [a,b,c,d] order
// Order is preserved even though task 2 finishes first:
await promisePool([() => delay(30, 'a'), () => delay(5, 'b')], 2); // ['a', 'b']
taskFns holds functions. A task doesn't start until you call its function, which is how you control when it begins.concurrency tasks running; start the next only when one settles.taskFns order, not completion order (like Promise.all).taskFns resolves to []; concurrency larger than the task count just runs them all.You'll run a batch of async tasks with a fixed number of "workers" — each worker grabs the next task as soon as it finishes the last, and you collect the results in order.
Promise.all(tasks.map(fn => fn())) starts everything at once. That's fine for a few tasks, but if you have hundreds of API calls, firing them all together will blow through rate limits, exhaust sockets, or spike memory. A pool fixes a ceiling: run concurrency tasks, and each time one finishes, start the next unstarted one. The number in flight stays flat. You're building promisePool(taskFns, concurrency).
Picture concurrency workers. Each worker, in a loop, takes the next task off a shared queue, runs it, stores the result at that task's index, and then reaches for the next task — until the queue is empty. When all tasks are done, resolve with the results array. The key subtlety: results go into a fixed slot (results[i]), so the order is by task position, not by who finished first.
The obvious version ignores the cap entirely:
function promisePoolNaive(taskFns) {
return Promise.all(taskFns.map((fn) => fn()));
}
This gets the ordering right (that's Promise.all's job) but does no throttling — taskFns.map(fn => fn()) calls every factory immediately, so all tasks start at once. The whole point of a pool is the concurrency limit, and this has none. We need to start only concurrency tasks, and launch each subsequent one from a completion handler.
function promisePool(taskFns, concurrency) {
return new Promise((resolve, reject) => {
const results = new Array(taskFns.length);
let nextIndex = 0; // the next task to hand out
let completed = 0; // how many have finished
if (taskFns.length === 0) return resolve([]);
const startNext = () => {
if (nextIndex >= taskFns.length) return; // queue empty
const i = nextIndex++;
// Call the factory now (starts the task); Promise.resolve normalizes
// a non-promise return. Store the result at its OWN index.
Promise.resolve()
.then(() => taskFns[i]())
.then((value) => {
results[i] = value;
if (++completed === taskFns.length) resolve(results);
else startNext(); // a slot freed up — pull the next task
})
.catch(reject); // fail fast on the first rejection
};
// Prime the pool with `concurrency` workers (capped at the task count).
const workers = Math.min(concurrency, taskFns.length);
for (let i = 0; i < workers; i++) startNext();
});
}
module.exports = { promisePool };
The pool is primed with min(concurrency, taskFns.length) calls to startNext, so exactly that many tasks begin. Each task's completion handler does two things: it stores its result at results[i] — the task's own index, which is what preserves order — and then calls startNext() to pull the next queued task into the freed slot. nextIndex is the shared cursor into the queue, so no two workers grab the same task. When completed reaches the total, every slot has been recycled through every task, and we resolve. The .catch(reject) makes it fail fast, matching Promise.all.
Take promisePool([A, B, C], 2) where the tasks take 30ms, 5ms, 15ms:
workers = min(2, 3) = 2. startNext() runs twice: task A starts (i = 0), task B starts (i = 1). nextIndex is now 2. C waits.B resolves first. results[1] = B's value; completed = 1. A slot freed → startNext() starts C (i = 2). Now A and C are running.C resolves. results[2] = C; completed = 2. startNext() finds nextIndex >= 3 → nothing to start.A resolves. results[0] = A; completed = 3 → resolve results.At no point were more than two tasks live. B finished first but landed in results[1], so the final array is [A, B, C] — task order, not finish order.
Promise objects, they've already started; the pool can't throttle what's already running. Pass functions and call them yourself.results[i] so the output matches taskFns order.concurrency workers, then let each recycle.concurrency workers when there are fewer tasks would call startNext past the end; cap the prime at taskFns.length (or guard inside startNext).allSettled-style pool keeps going after a rejection, recording { status, value/reason } per task. Swap the catch(reject) for storing the error and continuing.p-limit / Promise.map — libraries generalize this with per-call limiters and streaming results; the core idea is exactly this "prime N, refill on settle" loop.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.