All questions

Async Semaphore

Premium

Async Semaphore

An async semaphore is a concurrency limiter: it hands out a fixed number of permits, lets that many callers run at once, and makes everyone else wait in line until a permit is released. It is the classic tool for capping how much work happens in parallel — five uploads at a time, ten database connections, three in-flight API calls — without dropping any of the queued work. A mutex (mutual-exclusion lock) is just the special case where the permit count is one.

Implement asyncSemaphore(max). It returns a semaphore with three methods: acquire() resolves to a release function once a permit is free; available() reports how many permits are not currently held; and runExclusive(fn) acquires a permit, runs fn, and releases the permit afterward.

Signature

type Release = () => void;

interface Semaphore {
  acquire(): Promise<Release>; // resolves when a permit is free; call Release to give it back
  available(): number; // permits not currently held
  runExclusive<T>(fn: () => T | Promise<T>): Promise<T>; // acquire, run fn, always release
}

function asyncSemaphore(max: number): Semaphore;

Examples

const sem = asyncSemaphore(2);

const r1 = await sem.acquire(); // permit 1 of 2
const r2 = await sem.acquire(); // permit 2 of 2
sem.available(); // 0 — both permits are held

const pending = sem.acquire(); // waits: no permit is free yet
r1(); // release one permit -> the waiting acquire can now resolve
const r3 = await pending; // gets the freed permit (FIFO)
// Fetch many URLs but keep only 2 requests in flight at a time.
const sem = asyncSemaphore(2);

const bodies = await Promise.all(
  urls.map((url) => sem.runExclusive(() => fetch(url).then((r) => r.text()))),
);
// runExclusive releases the permit after each fetch, even if one rejects.

Notes

  • Permits, not a boolean — with a max of 3, three callers hold permits at once. A plain locked/unlocked flag can only model a max of 1; you need to count permits.
  • FIFO queue — waiters resume in the order they called acquire(). The freed permit goes to whoever has waited longest, not to whoever asks next.
  • Release is one-shot — calling a release function twice must free only one permit. Guard against double-release, or the count drifts and too many callers slip through.
  • runExclusive always releases — even when fn throws or its promise rejects, the permit must come back; otherwise one failure leaks a permit forever.
  • Assume a valid limit — you can assume max is at least 1. You do not need to handle a semaphore that admits nobody.
  • No timers — this is pure promise plumbing. You never need setTimeout; the release calls drive everything.

FAQ

How is a semaphore different from a mutex?
A mutex is the special case of a semaphore with max = 1, so only one holder runs at a time. A semaphore generalizes that to N concurrent holders, which is why it is used to cap concurrency rather than to serialize access.
Why does acquire() return a release function instead of a semaphore.release() method?
Returning a per-acquire release makes each permit its own one-shot handle: a holder can only give back the permit it took, and guarding the handle so it fires once prevents a stray double-release from over-crediting the pool.
What happens if a holder forgets to release?
The permit is never returned, so the queue stalls and every later acquire() waits forever. That is exactly why runExclusive releases inside a finally block, so the permit comes back even when the task throws.

Unlock the solution & editor

  • Runnable editor + tests

    Solve in the browser with instant Jest feedback.

  • Detailed solutions

    Walkthroughs, edge cases, and complexity notes.

  • Multi-framework variants

    React, Vue, Vanilla, Angular — same question, different stacks.

Upgrade to Premium