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.
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;
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.
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.acquire(). The freed permit goes to whoever has waited longest, not to whoever asks next.runExclusive always releases — even when fn throws or its promise rejects, the permit must come back; otherwise one failure leaks a permit forever.max is at least 1. You do not need to handle a semaphore that admits nobody.setTimeout; the release calls drive everything.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.