mrkeyoor.com_
Tue 10 Nov 22:14 UTC
npmUtilsupdated 10 Nov 2026

async-mutex review

Two calls can read the same value before either finishes an awaited write. async-mutex 0.5.0 gives cooperating JavaScript callers a shared lock around that whole operation. Its Mutex allows one holder, while its Semaphore lets work acquire a chosen number of permits. runExclusive releases the lock when its callback settles, including rejection. The boundary is the shared object in memory: another server process or a caller that bypasses it can still change the resource.

Verdict

Use async-mutex when all participating callers can share one lock around the complete async operation. Before adding it, trace every writer and check for nested acquisition of that same lock.

Answers from our run

async-mutex or p-limit: which should you use?

p-limit: Choose p-limit when the job is limiting concurrent promise-returning functions rather than managing explicit lock acquisitions. Use async-mutex when all participating callers can share one lock around the complete async operation.

When should you not use async-mutex?

Several processes or machines write the same data. Use coordination at that shared data boundary, such as a database transaction, rather than an independent lock in each process.

API stability3/5The 0.5.0 documentation defines acquisition, callback results, cancellation and manual release, and the matching implementation shows a finally block returning permits. That gives callers an inspectable contract. It does not establish a future compatibility promise or a tested upgrade path, so this score reflects contract clarity rather than an observed history of painless upgrades.
Docs4/5The tagged README includes both promise and async/await examples, explains idempotent scoped release, and warns that forgetting release can deadlock an application. It also distinguishes waiting for availability from acquiring a lock and says cancellation leaves held locks intact. The scheduler source is still needed to see why a heavy request at the queue head can delay smaller ones.
Maintenance2/5There is an inspectable 0.5.0 release tag, and the published package records lint, test and build commands, including a prepublishOnly step that requests tests and a build. Those are maintenance mechanisms, not evidence that checks passed for this guide. No current response-time, support-cadence or upgrade testing evidence is included, which limits this rubric's maintenance score.
Ecosystem3/5The 0.5.0 registry metadata exposes import and require entry points, points to bundled declarations, and lists tslib as its runtime dependency. Those are concrete integration affordances for JavaScript and TypeScript consumers. They do not establish compatibility with your bundler or runtime, and no download count or community size contributes to this packaging-based score.

Use it if

  • Several async callers share mutable state and every relevant access can go through the same Mutex instance.
  • A multi-step conversation with one resource must finish before the next caller starts its conversation.
  • Work needs different numbers of permits from one shared capacity, using the Semaphore weight argument.
  • You need to reject pending lock acquisitions while letting the current holder finish.
Skip it if

Setup reality

Install the version described here with npm install async-mutex@0.5.0. The published metadata supplies CommonJS and ESM entry points, bundled TypeScript declarations and a runtime dependency on tslib. There is no separate lock service in this implementation. Create one lock for the resource and share that instance among its callers. Creating a Mutex inside each request gives each request its own allowance.

The examples below are documentation-based illustrations, checked against the tagged 0.5.0 documentation and implementation. They were not executed. This guide reports no installation timing, audit result, runtime compatibility test or benchmark. Application functions such as readBalance and writeBalance are placeholders for your own code. The surrounding application must handle persistence failures and decide whether the resource also needs a transaction.

Keep every asynchronous step that needs protection inside the returned callback promise. Starting a write without awaiting or returning it lets the callback finish and release the lock before that write completes. For manual acquisition, put the returned release function in finally. Prefer that scoped function over calling release on the shared lock object, since the scoped releaser belongs to your acquisition. A hung callback can keep the lock occupied indefinitely. Re-entering the same Mutex from inside its holder can deadlock.

Semaphore capacity needs thought too. In 0.5.0 the dispatcher checks the first queued acquisition's weight against available permits. A large request at the front can therefore hold back smaller requests behind it. Higher priority changes queue position, but it does not interrupt a holder. Sustained higher-priority arrivals can delay ordinary work. Keep weights positive and use sensible integer permit counts. A weight above total capacity cannot run unless enough capacity becomes available.

Neither isLocked() nor waitForUnlock() reserves a turn. Acquire the lock before reading or changing protected state. cancel() clears pending acquisitions, without revoking held locks or permanently disabling the object, so shutdown also needs an application flag that stops new submissions. For independent tasks where you want results as they finish, compare the separate tiny-async-pool guide. That is a different decision from protecting one shared resource.

Ratings use a documentation-based 1 to 5 rubric: 1 means little inspectable support, 3 means a documented contract with meaningful limits, and 5 requires stronger evidence such as verified integration or upgrade results. API stability rates contract clarity, documentation rates actionable instructions, maintenance rates inspectable release and check tooling, and ecosystem rates packaging integration. These are editorial judgments, not measured reliability, adoption or maintainer response speed.

Patterns

Keep the read and write under one lock protect-read-modify-write

import {Mutex} from 'async-mutex';

const balanceLock = new Mutex();
async function addCredit(amount) {
  return balanceLock.runExclusive(async () => {
    const balance = await readBalance();
    await writeBalance(balance + amount);
  });
}

Share balanceLock across every participating reader and writer that needs this exclusion. The lock only coordinates callers using that object. Await the write before the callback finishes.

Return the value from protected work return-protected-result

import {Mutex} from 'async-mutex';

const lock = new Mutex();
const snapshot = await lock.runExclusive(async () => {
  const value = await loadCurrentValue();
  return {value};
});

runExclusive adopts the callback result. Returning an object does not keep it protected after the lock is released, so treat a returned reference to mutable shared state with care.

Release a manually acquired lock in finally release-manual-acquisition

import {Mutex} from 'async-mutex';

const lock = new Mutex();
const release = await lock.acquire();
try {
  await updateSharedState();
} finally {
  release();
}

Mutex.acquire resolves to a release function, not the tuple returned by Semaphore.acquire. The scoped function is idempotent, but omitting it leaves the lock held.

Handle errors outside the protected operation handle-protected-failure

import {Mutex} from 'async-mutex';

const lock = new Mutex();
try {
  await lock.runExclusive(async () => {
    await saveRecord();
  });
} catch (error) {
  console.error('Save failed', error);
}

The implementation awaits the callback and releases in finally when it rejects. Releasing the lock does not undo a partial save. Handle data recovery separately.

Allow up to three cooperating operations share-semaphore-capacity

import {Semaphore} from 'async-mutex';

const slots = new Semaphore(3);
async function load(url) {
  return slots.runExclusive(async () => {
    const response = await fetch(url);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.text();
  });
}

Each call takes the default weight of one. Returning response.text() keeps the callback pending through body consumption. All calls must use this same slots instance to share its capacity.

Reserve two permits and release the same weight acquire-weighted-permits

import {Semaphore} from 'async-mutex';

const capacity = new Semaphore(4);
const [availableBefore, release] = await capacity.acquire(2);
try {
  console.log({availableBefore});
  await performLargeOperation();
} finally {
  release();
}

The tuple's number is the value before this acquisition subtracts its weight. The scoped releaser restores two permits. A heavy request at the front of the queue can hold back lighter requests behind it.

Give urgent queued work a higher priority prioritize-waiting-work

import {Semaphore} from 'async-mutex';

const slots = new Semaphore(2);
async function submit(task, urgent = false) {
  return slots.runExclusive(() => task(), 1, urgent ? 10 : 0);
}

Semaphore.runExclusive takes callback, weight, then priority. Larger priorities sort ahead of smaller ones in the waiting queue. They do not preempt a running callback, and repeated urgent work can delay ordinary work.

Reject pending work and stop accepting new work cancel-pending-acquisitions

import {Mutex, E_CANCELED} from 'async-mutex';

const lock = new Mutex();
let stopping = false;
async function submit(task) {
  if (stopping) throw new Error('Stopping');
  try {
    return await lock.runExclusive(() => task());
  } catch (error) {
    if (error === E_CANCELED) return {cancelled: true};
    throw error;
  }
}
function stop() {
  stopping = true;
  lock.cancel();
}

cancel rejects currently pending acquisitions with the default E_CANCELED object. A holder keeps running. Cancellation itself is not a permanent closed state, which is why this example also stops new submissions.

Acquire even after an availability notification acquire-after-availability

import {Mutex} from 'async-mutex';

const lock = new Mutex();
await lock.waitForUnlock();
await lock.runExclusive(async () => {
  await updateSharedState();
});

waitForUnlock does not acquire or reserve the lock. This illustration intentionally acquires afterward and may wait again. For ordinary protected work, call runExclusive directly instead of adding the availability wait.

Alternatives

PackageRegistryPick it when
p-limitnpmChoose p-limit when the job is limiting concurrent promise-returning functions rather than managing explicit lock acquisitions.
await-semaphorenpmConsider await-semaphore when comparing another awaitable semaphore/mutex package. Its registry description establishes that scope, not feature parity or a migration path.

More utils guides

lru-cache · type-fest · ajv · zod · p-limit · find-up · the whole shelf →

How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.