TypeScript: running async tasks with a concurrency limit

Start at most N tasks at once, collect results in input order, and measure the peak concurrency.

TypeScript
async function tick(times: number): Promise<void> {
  for (let i = 0; i < times; i++) {
    await null;
  }
}

async function pool<T>(tasks: (() => Promise<T>)[], limit: number): Promise<T[]> {
  const results: T[] = new Array(tasks.length);
  let next = 0;
  async function worker(): Promise<void> {
    while (next < tasks.length) {
      const index = next++;
      results[index] = await tasks[index]();
    }
  }
  await Promise.all(Array.from({ length: limit }, () => worker()));
  return results;
}

let running = 0;
let peak = 0;
const finished: string[] = [];

function task(name: string, ticks: number): () => Promise<string> {
  return async () => {
    running++;
    peak = Math.max(peak, running);
    await tick(ticks);
    running--;
    finished.push(name);
    return name.toUpperCase();
  };
}

const tasks = [task("a", 10), task("b", 1), task("c", 2), task("d", 1)];
pool(tasks, 2).then((results) => {
  console.log("results in input order: " + results.join(","));
  console.log("finished order: " + finished.join(","));
  console.log("peak concurrency: " + peak);
});
Output
results in input order: A,B,C,D
finished order: b,c,d,a
peak concurrency: 2

Explanation

Starting thousands of asynchronous jobs at once can overwhelm a server, a database, or the network. A pool limits how many run at the same time. The pool has N workers, and each worker repeatedly takes the next unstarted task, awaits it, and stores its result at the task's own index.

Because results are stored by index, the result array keeps the input order even though tasks finish in a different order. Taking next++ is safe in JavaScript because there is only one thread running the code between awaits. The program records the largest number of tasks running at once to show that the limit holds.

Explore the dry run
Guided walkthroughReady
1async function tick(times: number): Promise<void> {
2 for (let i = 0; i < times; i++) {
3 await null;
4 }
5}
6
7async function pool<T>(tasks: (() => Promise<T>)[], limit: number): Promise<T[]> {
8 const results: T[] = new Array(tasks.length);
9 let next = 0;
10 async function worker(): Promise<void> {
11 while (next < tasks.length) {
12 const index = next++;
13 results[index] = await tasks[index]();
14 }
15 }
16 await Promise.all(Array.from({ length: limit }, () => worker()));
17 return results;
18}
19
20let running = 0;
21let peak = 0;
22const finished: string[] = [];
23
24function task(name: string, ticks: number): () => Promise<string> {
25 return async () => {
26 running++;
27 peak = Math.max(peak, running);
28 await tick(ticks);
29 running--;
30 finished.push(name);
31 return name.toUpperCase();
32 };
33}
34
35const tasks = [task("a", 10), task("b", 1), task("c", 2), task("d", 1)];
36pool(tasks, 2).then((results) => {
37 console.log("results in input order: " + results.join(","));
38 console.log("finished order: " + finished.join(","));
39 console.log("peak concurrency: " + peak);
40});

Follow the example

Start to inspect the source, values, and output. These prepared steps explain the published sample; grouped loop steps describe multiple iterations.

View the complete dry-run table
Prepared trace of the published program. Some rows group repeated iterations.
Step / lineWhat happensState after stepOutput so far
Each worker claims the next task index. Only one piece of code runs at a time between awaits, so two workers never claim the same task.—
—
The result is stored at the task's own position.—
—
Start the fixed number of workers and wait for all to finish.2 workers
—
Track how many tasks are running at once; it never exceeds the limit.2 workers
—
Results follow the input order a, b, c, d.2 workers
results in input order: A,B,C,D
Task a needs ten turns and the others only one or two, so b, c, and d finish before it.2 workers
results in input order: A,B,C,D
finished order: b,c,d,a
Never more than two tasks ran together.2 workers
results in input order: A,B,C,D
finished order: b,c,d,a
peak concurrency: 2

Cost and common mistakes

O(n) tasks started; at most `limit` run at once.

  • If a task rejects, Promise.all rejects at once while other workers keep going. Decide whether to stop or collect errors.
  • This pool limits concurrency, not parallelism. Async tasks on one thread still take turns.
  • Using a real timer such as setTimeout instead of tick makes the finishing order depend on the clock, which is realistic but less repeatable.

Try it yourself

What changes if the limit is 1?

Reveal answer and reasoning

The tasks run strictly one after another in input order, so the finished order equals the input order and the peak is 1.

Further reading