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.
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.
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.
- 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.
- Every code path cannot be made to use the same lock. A bypassing writer defeats the exclusion this library provides.
- A callback must acquire and await the very Mutex it already holds. That inner acquisition waits for the outer callback to release, leaving both stuck.
- You need cancellation to stop an operation that already holds a lock. cancel() rejects pending acquisitions and leaves held locks alone.
- You only need a concurrency cap for independent tasks. A task limiter may express that job more directly.
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
| Package | Registry | Pick it when |
|---|---|---|
| p-limit | npm | Choose p-limit when the job is limiting concurrent promise-returning functions rather than managing explicit lock acquisitions. |
| await-semaphore | npm | Consider 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.