typescript 46 lines · 6 steps

Building an async Semaphore in TypeScript

A counting semaphore that limits how many async tasks run at once by parking excess callers as pending promises.

Explained by highlit
1export class Semaphore {
2 private available: number;
3 private readonly waiters: Array<() => void> = [];
4 
5 constructor(permits: number) {
6 if (permits < 1) throw new RangeError("permits must be >= 1");
7 this.available = permits;
8 }
9 
10 async acquire(): Promise<void> {
11 if (this.available > 0) {
12 this.available--;
13 return;
14 }
15 await new Promise<void>((resolve) => this.waiters.push(resolve));
16 }
17 
18 release(): void {
19 const next = this.waiters.shift();
20 if (next) {
21 next();
22 } else {
23 this.available++;
24 }
25 }
26 
27 async run<T>(task: () => Promise<T>): Promise<T> {
28 await this.acquire();
29 try {
30 return await task();
31 } finally {
32 this.release();
33 }
34 }
35}
36 
37export async function mapWithLimit<T, R>(
38 items: readonly T[],
39 limit: number,
40 fn: (item: T, index: number) => Promise<R>,
41): Promise<R[]> {
42 const semaphore = new Semaphore(limit);
43 return Promise.all(
44 items.map((item, index) => semaphore.run(() => fn(item, index))),
45 );
46}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1A resolve function captured from a Promise can be stored and called later to unblock an awaiting caller.
  2. 2Counting permits plus a FIFO waiter queue is enough to bound concurrency without threads or locks.
  3. 3Wrapping work in acquire/finally-release guarantees a permit is always returned, even when the task throws.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Building an async Semaphore in TypeScript — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code