semaphore-mutex
v2.0.0
Published
A basic async semaphore and mutex implementation
Maintainers
Readme
semaphore-mutex
A small, dependency-free async semaphore and mutex for TypeScript and JavaScript.
Use it to cap how many things run at once — outbound HTTP requests, database writes, file handles, anything with a limit you would rather enforce than discover.
npm install semaphore-mutexShips ESM and CommonJS builds with bundled type declarations. No runtime dependencies. Requires Node 22.12 or newer.
Quick start
import { Semaphore } from 'semaphore-mutex';
const semaphore = new Semaphore(5); // at most 5 concurrent fetches
async function fetchThrottled(url: string): Promise<Response> {
const token = await semaphore.acquire();
try {
return await fetch(url);
} finally {
semaphore.release(token);
}
}
// 100 URLs, never more than 5 requests in flight
const responses = await Promise.all(urls.map(fetchThrottled));Always release in a finally. A throw between acquire and release leaks a
permit permanently, and enough leaks deadlock the semaphore.
CommonJS works too:
const { Semaphore, Mutex } = require('semaphore-mutex');Mutex
A Mutex is a Semaphore fixed at one permit, for guarding a critical section:
import { Mutex } from 'semaphore-mutex';
const mutex = new Mutex();
let balance = 0;
async function deposit(amount: number): Promise<void> {
const token = await mutex.acquire();
try {
const current = await readBalance();
await writeBalance(current + amount);
balance = current + amount;
} finally {
mutex.release(token);
}
}Because a mutex is defined by holding exactly one permit, setMaxCount() throws
for any value other than 1.
Waiting for quiet
waitIdle() resolves once every permit is back and nothing is queued — useful
for draining work before shutdown:
for (const job of jobs) {
void runJob(job); // each one acquires and releases internally
}
await semaphore.waitIdle();
console.log('all jobs finished');Concurrent callers share a single promise, and an already-idle semaphore resolves immediately.
Cancelling a pending acquire
Pass an AbortSignal to give up on a permit that is taking too long. The signal
only applies while queued — once you hold a permit, release it as normal.
const token = await semaphore.acquire({ signal: AbortSignal.timeout(1_000) });If the signal fires first, acquire() rejects and the request leaves the queue
without disturbing anyone behind it. The rejection is the signal's reason when
that reason is an Error (which is what AbortController.abort() and
AbortSignal.timeout() produce); otherwise it is an Error named AbortError
carrying the original reason as its cause.
Adjusting the limit at runtime
semaphore.setMaxCount(20);Raising the limit immediately admits as many queued waiters as now fit. Lowering
it never revokes permits already held — the count drains back below the new
limit as those are released, so getCurrentCount() may briefly exceed
getMaxCount().
Observing what's happening
Pass a logger to see permits move. It receives a level, a message, and a structured record of numbers:
const semaphore = new Semaphore(5, {
logger: (level, message, detail) => {
console[level === 'error' ? 'error' : 'debug'](message, detail);
},
});API
new Semaphore(maxCount, options?)
| Parameter | Type | Description |
| ---------------- | ----------------- | --------------------------------------------------------- |
| maxCount | number | Permits available at once. Must be a positive integer. |
| options.logger | SemaphoreLogger | Optional. Receives lifecycle events. Defaults to a no-op. |
Throws RangeError if maxCount is not a positive integer.
new Mutex(options?)
Equivalent to new Semaphore(1, options).
Methods
| Method | Returns | Description |
| ------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| acquire(options?) | Promise<SemaphoreToken> | Waits for a permit. Resolves on the next microtask when one is free, otherwise queues first-come-first-served. Rejects if options.signal aborts while queued. |
| release(token) | void | Returns a permit and hands it to the next waiter. Throws RangeError if the token is unknown or already released. |
| waitIdle() | Promise<void> | Resolves when nothing is held and nothing is queued. |
| getCurrentCount() | number | Permits currently held. |
| getBlockedCount() | number | Callers queued waiting for a permit. |
| getTotalCount() | number | getCurrentCount() + getBlockedCount(). |
| getMaxCount() | number | The current limit. |
| setMaxCount(n) | void | Changes the limit. Throws RangeError unless n is a positive integer — and, on a Mutex, unless n is 1. |
Exported types
SemaphoreToken, SemaphoreOptions, SemaphoreAcquireOptions,
SemaphoreLogger, SemaphoreLogLevel, SemaphoreLogDetail.
SemaphoreToken is a branded number. Only acquire() can mint one, so
handing release() an arbitrary number is a compile-time error rather than a
runtime surprise.
Migrating from 1.x
Version 2 fixes several defects that were not correctable without changing observable behaviour.
| Change | 1.x | 2.x |
| ---------------------------------------------- | -------------------------------------------------------- | ----------------------------------------- |
| acquire() return type | Promise<unknown>, so callers had to cast | Promise<SemaphoreToken> |
| waitIdle() return type | null when idle, Promise<void> otherwise | always Promise<void> |
| waitIdle() while callers are queued | Could resolve as "idle" with work pending | Waits for the queue to drain too |
| Releasing an unknown or already-released token | Silently ignored | Throws RangeError |
| new Semaphore(0) / new Semaphore(-1) | Accepted, then deadlocked on first acquire() | Throws RangeError |
| setMaxCount(-1) | Accepted, then deadlocked | Throws RangeError |
| Raising setMaxCount() | Queued waiters stayed blocked until an unrelated release | Admits waiters immediately |
| Mutex.setMaxCount(5) | Silently turned the mutex into a 5-permit semaphore | Throws RangeError |
| Scheduling | Deferred through setImmediate (Node-only) | Plain microtasks, so it runs anywhere |
| getTotalCount() | Included a pendingTaskCount that was always 0 | Held plus queued |
| Mutex | Not reachable from the package entry point | import { Mutex } from 'semaphore-mutex' |
| Module format | CommonJS only | ESM and CommonJS |
| Minimum Node | Unspecified | 22.12 |
import Semaphore from 'semaphore-mutex' and require('semaphore-mutex').default
both still resolve to the Semaphore class.
The most likely thing to break is code that released a token twice, or released a token it never held. That used to be a silent no-op and now throws.
Development
npm install
npm run verify # lint, format check, typecheck, test, build| Script | Description |
| ----------------------- | ------------------------------------------- |
| npm test | Run the test suite |
| npm run test:watch | Run tests in watch mode |
| npm run test:coverage | Run tests with a coverage report |
| npm run lint | oxlint, warnings treated as failures |
| npm run typecheck | tsc --noEmit |
| npm run format | Rewrite files with Prettier |
| npm run build | Emit the ESM and CommonJS builds to dist/ |
Releasing
CI runs on every push and pull request across Node 22, 24, and 26.
Pushing a v* tag triggers .github/workflows/release.yml, which re-runs the
full verification, checks the tag against package.json, uploads the tarball as
a workflow artifact, and then stages the release with npm stage publish.
Staging uploads the version to the registry in a held state — it is not
installable and does not take the latest tag.
To release it for real:
npm stage list semaphore-mutex
npm stage download <stage-id> # optional: inspect the exact tarball
npm stage approve <stage-id> # prompts for 2FA, then publishes
npm stage reject <stage-id> # or discard itThe stage subcommands require npm 11.15.0 or newer. Node 24 LTS bundles npm
11.17, so an up-to-date LTS toolchain already has them.
Authentication uses npm trusted publishing over OIDC, so there is no NPM_TOKEN
secret. Configure it once with:
npm trust github semaphore-mutex \
--file release.yml \
--env npm-staging \
--allow-stage-publishThe workflow's environment: npm-staging must match the --env value. Omit
--allow-publish so a compromised workflow can only stage, never publish.
License
MIT © Chris Kirmse, Chad Walker
