npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

semaphore-mutex

v2.0.0

Published

A basic async semaphore and mutex implementation

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-mutex

Ships 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 it

The 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-publish

The 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