async-mutex, lockfile, and mutexify are utilities designed to manage concurrent access to shared resources, but they operate at fundamentally different layers of the application stack. async-mutex provides an in-memory locking mechanism specifically for JavaScript async/await flows, ideal for protecting runtime variables or limiting concurrent tasks within a single process. lockfile operates at the file system level, creating physical lock files on the disk to prevent multiple processes (even across different machines or restarts) from modifying the same file simultaneously. mutexify is a lower-level wrapper around the fcntl system call, offering robust file locking with mandatory or advisory modes, often used for high-performance or strict inter-process synchronization needs.
In modern JavaScript development, especially in Node.js build tools, CLI utilities, and server-side rendering pipelines, managing concurrent access to resources is a frequent challenge. Whether you are preventing two webpack instances from writing to the same cache file or ensuring an API client doesn't overwhelm a endpoint, you need a locking strategy. The packages async-mutex, lockfile, and mutexify address this need but target completely different layers: memory, file system abstraction, and system calls.
async-mutex operates entirely in memory. It is designed to solve concurrency problems within a single running JavaScript process. It does not touch the disk or the operating system's kernel directly. This makes it incredibly fast and perfect for coordinating async functions that share variables.
A common use case is limiting the number of concurrent network requests or ensuring a specific block of code runs sequentially even if triggered multiple times.
// async-mutex: Protecting a shared in-memory counter
import { Mutex } from 'async-mutex';
const mutex = new Mutex();
let sharedCounter = 0;
async function increment() {
const release = await mutex.acquire();
try {
// Only one execution reaches here at a time
const current = sharedCounter;
await new Promise(r => setTimeout(r, 10)); // Simulate async work
sharedCounter = current + 1;
} finally {
release();
}
}
If your application crashes, the lock is lost immediately because it lives in RAM. This is fine for runtime logic but useless for coordinating separate processes.
lockfile solves a harder problem: synchronizing access across different processes. It works by creating a physical .lock file on your disk. If Process A holds the lock, Process B will wait (or fail) until that file is removed. This is essential for CLI tools, build scripts, or servers that might spawn multiple workers.
It abstracts away the complexity of checking for stale locks (e.g., if a process crashed without cleaning up).
// lockfile: Preventing two processes from writing to config.json
const lockfile = require('lockfile');
const fs = require('fs');
const lockPath = './config.json.lock';
lockfile.lock(lockPath, { wait: true }, (err) => {
if (err) throw err;
// Critical section: Safe to write to config.json
fs.writeFileSync('./config.json', JSON.stringify({ updated: true }));
// Always unlock when done
lockfile.unlock(lockPath, (err) => {
if (err) console.error('Failed to unlock', err);
});
});
This approach is slower than in-memory locks due to disk I/O, but it provides safety across process boundaries and even system restarts (if the lock file persists).
mutexify takes a more direct approach. Instead of managing temporary lock files, it wraps the native POSIX fcntl system call. This allows it to place an actual lock on the file descriptor itself. It is often preferred in high-performance scenarios or when you need strict adherence to OS-level locking semantics (like shared vs. exclusive locks).
It is less about "creating a lock file" and more about "locking the resource directly."
// mutexify: Locking a file descriptor directly
const mutexify = require('mutexify');
const fs = require('fs');
const unlock = mutexify('./database.db');
// Open the file first
const fd = fs.openSync('./database.db', 'r+');
unlock(fd, (release) => {
// We now have an exclusive lock on the file descriptor
fs.writeSync(fd, 'Safe write operation');
// Release the lock
release();
fs.closeSync(fd);
});
Because it relies on file descriptors, mutexify requires you to manage the file opening/closing lifecycle more explicitly than lockfile. However, it avoids the overhead of creating and deleting temporary lock files.
One of the biggest risks in locking is a process crashing while holding a lock, leaving other processes stuck forever.
async-mutex has no concept of stale locks. If the process dies, the lock vanishes instantly. This is safe by default for single-process apps but offers no cross-process protection.
lockfile includes built-in logic to detect stale locks. It can check the timestamp of the lock file and decide if the owner process is still alive. If a process crashed, lockfile can automatically break the lock after a timeout.
// lockfile: Handling stale locks with options
lockfile.lock('./data.lock', {
wait: true,
stale: 60000 // Break lock if owner hasn't touched it in 60s
}, callback);
mutexify relies on the operating system. When a process terminates, the OS automatically releases any fcntl locks held by that process. This makes it very robust against crashes without needing custom "stale" logic, but it offers less configurability than lockfile regarding timeouts.
| Feature | async-mutex | lockfile | mutexify |
|---|---|---|---|
| Scope | Single Process (In-Memory) | Multi-Process (File System) | Multi-Process (File Descriptor) |
| Mechanism | JavaScript Promise Queue | Temporary .lock Files | Native fcntl Calls |
| Crash Safety | Lock lost immediately | Detects stale locks via time | OS auto-releases on exit |
| Performance | Very Fast (No I/O) | Moderate (Disk I/O) | Fast (Syscall only) |
| Complexity | Low | Low | Medium (FD management) |
You are running a Next.js build that fetches content from a CMS. You need to ensure no more than 5 requests happen at once to avoid rate limiting.
async-mutex (often combined with a semaphore pattern).You built a CLI tool that users might run in multiple terminal windows simultaneously. It updates a global ~/.mytool-config file.
lockfile.You are writing a lightweight JSON database in Node.js that handles high-frequency writes.
mutexify.As of the latest checks, lockfile (the package by npm/isaacs) is considered legacy. While still functional, many modern projects have moved to alternatives like proper-lockfile which offers better Windows support and more modern promise-based APIs. If starting a new project today requiring file locking, evaluate if proper-lockfile or native Node.js fs features might serve you better, though lockfile remains a stable reference for understanding the pattern. async-mutex and mutexify remain actively relevant for their specific niches.
Choosing the right tool depends entirely on your boundary of trust. If you only trust your own code within one process, async-mutex is the cleanest solution. If you need to trust other processes on the same machine, you must go to the file system. There, the choice splits between the convenience of lockfile's temporary files and the raw power of mutexify's system calls. Understanding these distinctions prevents subtle race conditions that can corrupt data or crash production builds.
Choose async-mutex when you need to coordinate asynchronous tasks within a single Node.js process or browser environment. It is the best fit for protecting in-memory state, limiting API call concurrency, or ensuring sequential execution of async functions without touching the file system. Avoid it if you need to synchronize access across different processes or after a server restart, as the locks exist only in RAM.
Choose lockfile when you need to prevent race conditions between multiple distinct processes accessing the same file on disk. This is common in build tools, CLI utilities, or daemons where you must ensure only one instance writes to a log or config file at a time. It is suitable for scenarios where simplicity is key and you don't need advanced system-level locking flags, but be aware it relies on creating temporary files.
Choose mutexify when you require robust, system-level file locking using native fcntl calls. It is appropriate for high-stakes environments where lock reliability is critical, such as database engines or heavy-duty file processors that need mandatory locking semantics. Use this if lockfile's approach of creating temporary files feels too fragile or if you need finer control over lock behavior (shared vs. exclusive) at the OS level.
This package implements primitives for synchronizing asynchronous operations in Javascript.
The term "mutex" usually refers to a data structure used to synchronize concurrent processes running on different threads. For example, before accessing a non-threadsafe resource, a thread will lock the mutex. This is guaranteed to block the thread until no other thread holds a lock on the mutex and thus enforces exclusive access to the resource. Once the operation is complete, the thread releases the lock, allowing other threads to acquire a lock and access the resource.
While Javascript is strictly single-threaded, the asynchronous nature of its execution model allows for race conditions that require similar synchronization primitives. Consider for example a library communicating with a web worker that needs to exchange several subsequent messages with the worker in order to achieve a task. As these messages are exchanged in an asynchronous manner, it is perfectly possible that the library is called again during this process. Depending on the way state is handled during the async process, this will lead to race conditions that are hard to fix and even harder to track down.
This library solves the problem by applying the concept of mutexes to Javascript. Locking the mutex will return a promise that resolves once the mutex becomes available. Once the async process is complete (usually taking multiple spins of the event loop), a callback supplied to the caller should be called in order to release the mutex, allowing the next scheduled worker to execute.
Imagine a situation where you need to control access to several instances of a shared resource. For example, you might want to distribute images between several worker processes that perform transformations, or you might want to create a web crawler that performs a defined number of requests in parallel.
A semaphore is a data structure that is initialized with an arbitrary integer value and that can be locked multiple times. As long as the semaphore value is positive, locking it will return the current value and the locking process will continue execution immediately; the semaphore will be decremented upon locking. Releasing the lock will increment the semaphore again.
Once the semaphore has reached zero, the next process that attempts to acquire a lock will be suspended until another process releases its lock and this increments the semaphore again.
This library provides a semaphore implementation for Javascript that is similar to the mutex implementation described above.
You can install the library into your project via npm
npm install async-mutex
The library is written in TypeScript and will work in any environment that
supports ES5, ES6 promises and Array.isArray. On ancient browsers,
a shim can be used (e.g. core-js).
No external typings are required for using this library with
TypeScript (version >= 2).
Starting with Node 12.16 and 13.7, native ES6 style imports are supported.
WARNING: Node 13 versions < 13.2.0 fail to import this package correctly. Node 12 and earlier are fine, as are newer versions of Node 13.
CommonJS:
var Mutex = require('async-mutex').Mutex;
var Semaphore = require('async-mutex').Semaphore;
var withTimeout = require('async-mutex').withTimeout;
ES6:
import {Mutex, Semaphore, withTimeout} from 'async-mutex';
TypeScript:
import {Mutex, MutexInterface, Semaphore, SemaphoreInterface, withTimeout} from 'async-mutex';
With the latest version of Node, native ES6 style imports are supported.
const mutex = new Mutex();
Create a new mutex.
Promise style:
mutex
.runExclusive(() => {
// ...
})
.then((result) => {
// ...
});
async/await:
await mutex.runExclusive(async () => {
// ...
});
runExclusive schedules the supplied callback to be run once the mutex is unlocked.
The function may return a promise. Once the promise is resolved or rejected (or immediately after
execution if an immediate value was returned),
the mutex is released. runExclusive returns a promise that adopts the state of the function result.
The mutex is released and the result rejected if an exception occurs during execution of the callback.
Promise style:
mutex
.acquire()
.then(function(release) {
// ...
release();
});
async/await:
const release = await mutex.acquire();
try {
// ...
} finally {
release();
}
acquire returns an (ES6) promise that will resolve as soon as the mutex is
available. The promise resolves with a function release that
must be called once the mutex should be released again. The release callback
is idempotent.
IMPORTANT: Failure to call release will hold the mutex locked and will
likely deadlock the application. Make sure to call release under all circumstances
and handle exceptions accordingly.
As an alternative to calling the release callback returned by acquire, the mutex
can be released by calling release directly on it:
mutex.release();
mutex.isLocked();
Pending locks can be cancelled by calling cancel() on the mutex. This will reject
all pending locks with E_CANCELED:
Promise style:
import {E_CANCELED} from 'async-mutex';
mutex
.runExclusive(() => {
// ...
})
.then(() => {
// ...
})
.catch(e => {
if (e === E_CANCELED) {
// ...
}
});
async/await:
import {E_CANCELED} from 'async-mutex';
try {
await mutex.runExclusive(() => {
// ...
});
} catch (e) {
if (e === E_CANCELED) {
// ...
}
}
This works with acquire, too:
if acquire is used for locking, the resulting promise will reject with E_CANCELED.
The error that is thrown can be customized by passing a different error to the Mutex
constructor:
const mutex = new Mutex(new Error('fancy custom error'));
Note that while all pending locks are cancelled, a currently held lock will not be
revoked. In consequence, the mutex may not be available even after cancel() has been called.
You can wait until the mutex is available without locking it by calling waitForUnlock().
This will return a promise that resolve once the mutex can be acquired again. This operation
will not lock the mutex, and there is no guarantee that the mutex will still be available
once an async barrier has been encountered.
Promise style:
mutex
.waitForUnlock()
.then(() => {
// ...
});
Async/await:
await mutex.waitForUnlock();
// ...
const semaphore = new Semaphore(initialValue);
Creates a new semaphore. initialValue is an arbitrary integer that defines the
initial value of the semaphore.
Promise style:
semaphore
.runExclusive(function(value) {
// ...
})
.then(function(result) {
// ...
});
async/await:
await semaphore.runExclusive(async (value) => {
// ...
});
runExclusive schedules the supplied callback to be run once the semaphore is available.
The callback will receive the current value of the semaphore as its argument.
The function may return a promise. Once the promise is resolved or rejected (or immediately after
execution if an immediate value was returned),
the semaphore is released. runExclusive returns a promise that adopts the state of the function result.
The semaphore is released and the result rejected if an exception occurs during execution of the callback.
runExclusive accepts a first optional argument weight. Specifying a weight will decrement the
semaphore by the specified value, and the callback will only be invoked once the semaphore's
value greater or equal to weight.
runExclusive accepts a second optional argument priority. Specifying a greater value for priority
tells the scheduler to run this task before other tasks. priority can be any real number. The default
is zero.
Promise style:
semaphore
.acquire()
.then(function([value, release]) {
// ...
release();
});
async/await:
const [value, release] = await semaphore.acquire();
try {
// ...
} finally {
release();
}
acquire returns an (ES6) promise that will resolve as soon as the semaphore is
available. The promise resolves to an array with the
first entry being the current value of the semaphore, and the second value a
function that must be called to release the semaphore once the critical operation
has completed. The release callback is idempotent.
IMPORTANT: Failure to call release will hold the semaphore locked and will
likely deadlock the application. Make sure to call release under all circumstances
and handle exceptions accordingly.
acquire accepts a first optional argument weight. Specifying a weight will decrement the
semaphore by the specified value, and the semaphore will only be acquired once its
value is greater or equal to weight.
acquire accepts a second optional argument priority. Specifying a greater value for priority
tells the scheduler to release the semaphore to the caller before other callers. priority can be
any real number. The default is zero.
As an alternative to calling the release callback returned by acquire, the semaphore
can be released by calling release directly on it:
semaphore.release();
release accepts an optional argument weight and increments the semaphore accordingly.
IMPORTANT: Releasing a previously acquired semaphore with the releaser that was
returned by acquire will automatically increment the semaphore by the correct weight. If
you release by calling the unscoped release you have to supply the correct weight
yourself!
semaphore.getValue()
semaphore.isLocked();
The semaphore is considered to be locked if its value is either zero or negative.
The value of a semaphore can be set directly to a desired value. A positive value will cause the semaphore to schedule any pending waiters accordingly.
semaphore.setValue();
Pending locks can be cancelled by calling cancel() on the semaphore. This will reject
all pending locks with E_CANCELED:
Promise style:
import {E_CANCELED} from 'async-mutex';
semaphore
.runExclusive(() => {
// ...
})
.then(() => {
// ...
})
.catch(e => {
if (e === E_CANCELED) {
// ...
}
});
async/await:
import {E_CANCELED} from 'async-mutex';
try {
await semaphore.runExclusive(() => {
// ...
});
} catch (e) {
if (e === E_CANCELED) {
// ...
}
}
This works with acquire, too:
if acquire is used for locking, the resulting promise will reject with E_CANCELED.
The error that is thrown can be customized by passing a different error to the Semaphore
constructor:
const semaphore = new Semaphore(2, new Error('fancy custom error'));
Note that while all pending locks are cancelled, any currently held locks will not be
revoked. In consequence, the semaphore may not be available even after cancel() has been called.
You can wait until the semaphore is available without locking it by calling waitForUnlock().
This will return a promise that resolve once the semaphore can be acquired again. This operation
will not lock the semaphore, and there is no guarantee that the semaphore will still be available
once an async barrier has been encountered.
Promise style:
semaphore
.waitForUnlock()
.then(() => {
// ...
});
Async/await:
await semaphore.waitForUnlock();
// ...
waitForUnlock accepts optional arguments weight and priority. The promise will resolve as soon
as it is possible to acquire the semaphore with the given weight and priority. Scheduled tasks with
the greatest priority values execute first.
Sometimes it is desirable to limit the time a program waits for a mutex or
semaphore to become available. The withTimeout decorator can be applied
to both semaphores and mutexes and changes the behavior of acquire and
runExclusive accordingly.
import {withTimeout, E_TIMEOUT} from 'async-mutex';
const mutexWithTimeout = withTimeout(new Mutex(), 100);
const semaphoreWithTimeout = withTimeout(new Semaphore(5), 100);
The API of the decorated mutex or semaphore is unchanged.
The second argument of withTimeout is the timeout in milliseconds. After the
timeout is exceeded, the promise returned by acquire and runExclusive will
reject with E_TIMEOUT. The latter will not run the provided callback in case
of an timeout.
The third argument of withTimeout is optional and can be used to
customize the error with which the promise is rejected.
const mutexWithTimeout = withTimeout(new Mutex(), 100, new Error('new fancy error'));
const semaphoreWithTimeout = withTimeout(new Semaphore(5), 100, new Error('new fancy error'));
A shortcut exists for the case where you do not want to wait for a lock to
be available at all. The tryAcquire decorator can be applied to both mutexes
and semaphores and changes the behavior of acquire and runExclusive to
immediately throw E_ALREADY_LOCKED if the mutex is not available.
Promise style:
import {tryAcquire, E_ALREADY_LOCKED} from 'async-mutex';
tryAcquire(semaphoreOrMutex)
.runExclusive(() => {
// ...
})
.then(() => {
// ...
})
.catch(e => {
if (e === E_ALREADY_LOCKED) {
// ...
}
});
async/await:
import {tryAcquire, E_ALREADY_LOCKED} from 'async-mutex';
try {
await tryAcquire(semaphoreOrMutex).runExclusive(() => {
// ...
});
} catch (e) {
if (e === E_ALREADY_LOCKED) {
// ...
}
}
Again, the error can be customized by providing a custom error as second argument to
tryAcquire.
tryAcquire(semaphoreOrMutex, new Error('new fancy error'))
.runExclusive(() => {
// ...
});
Feel free to use this library under the conditions of the MIT license.