async-lock, lockfile, and proper-lockfile are essential utilities for managing concurrency in Node.js applications, but they solve different problems. async-lock is an in-memory mutex library designed to serialize asynchronous operations within a single running process, preventing race conditions on shared variables or resources. lockfile is a legacy utility for creating lock files on the filesystem to coordinate access between multiple processes, though it is now deprecated. proper-lockfile is the modern, robust successor to lockfile, offering reliable cross-process file locking with automatic stale lock detection, retry mechanisms, and updates, making it safe for production environments where multiple Node.js instances or scripts access the same files.
Managing access to shared resources is a fundamental challenge in Node.js development. Whether you are preventing race conditions on an in-memory cache or ensuring two separate scripts don't corrupt the same log file, you need reliable locking mechanisms. The packages async-lock, lockfile, and proper-lockfile address these needs, but they operate at different levels: one manages code execution within a process, while the others manage file access across processes.
The most critical distinction is where the lock lives. async-lock operates entirely in memory within a single Node.js process. It acts as a mutex (mutual exclusion) to ensure that only one asynchronous function executes a critical section at a time. In contrast, lockfile and proper-lockfile create actual files on your disk (.lock files) to signal to the operating system and other processes that a resource is busy.
async-lock is used when you have shared variables in memory that multiple async functions might try to modify simultaneously.
// async-lock: Protecting in-memory state within one process
const AsyncLock = require('async-lock');
const lock = new AsyncLock();
let sharedCounter = 0;
async function increment() {
await lock.acquire('counter-key', async () => {
// Only one function can be here at a time
const current = sharedCounter;
await new Promise(r => setTimeout(r, 10)); // Simulate async work
sharedCounter = current + 1;
});
}
proper-lockfile creates a physical file on the disk to stop other processes from touching a target file. This works even if the processes are completely unrelated.
// proper-lockfile: Protecting a file across multiple processes
const properLockfile = require('proper-lockfile');
const fs = require('fs');
async function writeToFile() {
// Create a lock for 'data.txt'
const release = await properLockfile.lock('data.txt');
try {
// Safe to write; other processes are blocked
fs.writeFileSync('data.txt', 'new content');
} finally {
// Always release the lock
await release();
}
}
lockfile attempts the same file-based approach but uses older, less reliable methods.
// lockfile: Legacy file locking (Deprecated)
const lockfile = require('lockfile');
lockfile.lock('data.txt', function (err) {
if (err) return console.error('Could not lock');
// Perform file operations
// Must manually unlock
lockfile.unlock('data.txt', function (err) {
if (err) console.error('Failed to unlock');
});
});
One of the biggest risks in file locking is the "stale lock" problem. If a process crashes or is killed (e.g., kill -9) while holding a lock, a naive locking system will leave the lock file behind forever. This blocks all future processes from ever accessing the resource again.
lockfile does not handle this well. It relies on simple file existence checks. If a process dies unexpectedly, the lock remains, and you often have to manually delete the lock file to recover. This makes it dangerous for production systems.
proper-lockfile solves this by using atomic operations and monitoring. It periodically updates the lock file's metadata while the lock is held. If the process dies, the updates stop. Other processes checking the lock will see it hasn't been updated recently and will treat it as stale, allowing them to safely steal the lock and proceed.
// proper-lockfile: Automatically handles stale locks via options
const release = await properLockfile.lock('data.txt', {
stale: 10000, // Consider lock stale if not updated in 10s
update: 2000, // Update lock every 2s to show we are alive
retries: {
retries: 5, // Retry 5 times if lock is busy
factor: 2, // Exponential backoff
maxTimeout: 1000
}
});
// If the previous process crashed, proper-lockfile detects it
// and acquires the lock automatically after the 'stale' timeout.
There is no equivalent safety mechanism in async-lock because if a Node.js process crashes, all in-memory locks vanish instantly. However, async-lock does offer options to limit how long a task can wait before giving up, preventing indefinite hangs.
// async-lock: Preventing indefinite waits
await lock.acquire('key', async () => {
// Critical section
}, {
timeout: 5000, // Throw error if waiting longer than 5s
maxOccupationTime: 2000 // Throw error if task runs longer than 2s
});
Modern Node.js development heavily favors Promises and async/await. The API design of these libraries reflects their age and target use cases.
async-lock is built for Promises. It returns a Promise from the acquire method, making it seamless to use with modern async functions. You can also wrap the acquisition in a try/finally block to ensure locks are always released, even if an error occurs.
// async-lock: Native Promise support
async function safeOperation() {
try {
await lock.acquire('resource', async () => {
await doSomethingAsync();
await doAnotherThing();
});
} catch (err) {
console.error('Lock acquisition failed or task threw', err);
}
}
proper-lockfile also provides a clean Promise-based API. The lock method returns a Promise that resolves to a release function. This pattern is explicit and easy to reason about.
// proper-lockfile: Promise-based with explicit release function
async function safeFileWrite() {
let release;
try {
release = await properLockfile.lock('config.json');
await fs.promises.writeFile('config.json', '{"updated": true}');
} catch (err) {
console.error('Failed to lock or write', err);
} finally {
if (release) await release();
}
}
lockfile was written in an era where callbacks were the standard. While you can wrap it in a Promise yourself, the native API requires nested callbacks, which can lead to "callback hell" and makes error handling more verbose.
// lockfile: Callback-based API (requires wrapping for async/await)
function lockFilePromise(file) {
return new Promise((resolve, reject) => {
lockfile.lock(file, (err) => {
if (err) reject(err);
else resolve(() => {
return new Promise((res, rej) => {
lockfile.unlock(file, (err) => err ? rej(err) : res());
});
});
});
});
}
Understanding when to use which tool comes down to your architecture.
You have a single Node.js server that needs to call an external API, but the API only allows one request at a time per account. You need to queue these requests internally.
async-lockasync-lock efficiently queues the promises in memory.// Using async-lock for rate limiting
const lock = new AsyncLock();
async function callExternalApi(userId) {
return lock.acquire(`user-${userId}`, async () => {
return fetch(`https://api.example.com/data?user=${userId}`);
});
}
You have several CI/CD jobs or local developer terminals running build scripts that all read/write to the same node_modules/.cache file. These are separate processes.
proper-lockfileasync-lock won't work because the processes don't share memory. lockfile is too risky if a build script crashes. proper-lockfile ensures only one script writes at a time and recovers automatically if a script is killed.// Using proper-lockfile for cache safety
async function updateCache(data) {
const release = await properLockfile.lock('cache.json', { stale: 60000 });
try {
const current = JSON.parse(await fs.promises.readFile('cache.json'));
await fs.promises.writeFile('cache.json', JSON.stringify({ ...current, ...data }));
} finally {
await release();
}
}
You are maintaining an old tool written 8 years ago that uses lockfile.
proper-lockfile.lockfile invites hard-to-debug deadlocks in modern environments. The API is similar enough that migration is straightforward, but the reliability gain is massive.| Feature | async-lock | proper-lockfile | lockfile |
|---|---|---|---|
| Scope | In-Memory (Single Process) | File System (Cross-Process) | File System (Cross-Process) |
| Status | β Active & Maintained | β Active & Maintained | β Deprecated |
| API Style | Promises / Async-Await | Promises / Async-Await | Callbacks |
| Stale Handling | N/A (Memory clears on crash) | β Automatic Detection & Recovery | β Manual Intervention Required |
| Retry Logic | Built-in timeout options | β Configurable retries & backoff | β Basic / Manual |
| Use Case | Race conditions on variables | Coordinating file access | (Legacy only) |
Choosing the right locking mechanism prevents some of the hardest bugs to reproduce in distributed systems.
async-lock is your go-to for internal consistency. If your problem is "two async functions in my server are fighting over this variable," this is the lightweight, efficient solution. It adds almost no overhead and integrates perfectly with modern JavaScript syntax.
proper-lockfile is the industry standard for file safety. If your problem is "two different scripts might overwrite this file," you need a lock that lives on the disk. Its ability to detect crashed processes and clean up after them makes it robust enough for critical infrastructure, build tools, and multi-process applications.
lockfile belongs in the past. Its deprecation is a clear signal that its approach to file locking is insufficient for the reliability demands of modern software. Migrating away from it should be a priority for any team still relying on it.
Final Thought: Always match the lock scope to your problem scope. Don't use a heavy file lock for in-memory problems, and never trust a memory lock to solve cross-process conflicts.
Choose async-lock when you need to prevent race conditions between asynchronous functions running within the same Node.js process. It is ideal for protecting shared in-memory state, limiting concurrency for specific tasks (like API calls), or serializing database transactions within a single server instance. Do not use it if you need to coordinate access between separate processes or different servers, as it relies entirely on memory.
Do NOT choose lockfile for any new project. This package is officially deprecated and no longer maintained. It lacks critical features like automatic stale lock cleanup and robust error handling, which can lead to deadlocks where files remain locked forever if a process crashes. Existing projects using it should plan a migration to proper-lockfile immediately.
Choose proper-lockfile when you need to coordinate file access between multiple separate processes, such as different Node.js scripts, worker threads, or distinct server instances accessing a shared file system. It is the correct choice for build tools, cache managers, or any application where file integrity across process boundaries is critical. Its built-in retry logic and stale lock detection make it safe for production use where crashes might occur.
Lock on asynchronous code
I did not create this package, and I will not add any features to it myself. I was granted the ownership because it was no longer being maintained, and I volunteered to fix a bug.
If you have a new feature you would like to have incorporated, please send me a PR and I will be happy to work with you and get it merged. For any bugs, PRs are most welcome but when possible I will try to get them resolved as soon as possible.
Nodejs is single threaded, and the code execution never gets interrupted inside an event loop, so locking is unnecessary? This is true ONLY IF your critical section can be executed inside a single event loop. However, if you have any async code inside your critical section (it can be simply triggered by any I/O operation, or timer), your critical logic will across multiple event loops, therefore it's not concurrency safe!
Consider the following code
redis.get('key', function(err, value) {
redis.set('key', value * 2);
});
The above code simply multiply a redis key by 2. However, if two users run concurrently, the execution order may like this
user1: redis.get('key') -> 1
user2: redis.get('key') -> 1
user1: redis.set('key', 1 x 2) -> 2
user2: redis.set('key', 1 x 2) -> 2
Obviously it's not what you expected
With asyncLock, you can easily write your async critical section
lock.acquire('key', function(cb) {
// Concurrency safe
redis.get('key', function(err, value) {
redis.set('key', value * 2, cb);
});
}, function(err, ret) {
});
var AsyncLock = require('async-lock');
var lock = new AsyncLock();
/**
* @param {String|Array} key resource key or keys to lock
* @param {function} fn execute function
* @param {function} cb (optional) callback function, otherwise will return a promise
* @param {Object} opts (optional) options
*/
lock.acquire(key, function(done) {
// async work
done(err, ret);
}, function(err, ret) {
// lock released
}, opts);
// Promise mode
lock.acquire(key, function() {
// return value or promise
}, opts).then(function() {
// lock released
});
// Callback mode
lock.acquire(key, function(done) {
done(new Error('error'));
}, function(err, ret) {
console.log(err.message) // output: error
});
// Promise mode
lock.acquire(key, function() {
throw new Error('error');
}).catch(function(err) {
console.log(err.message) // output: error
});
lock.acquire([key1, key2], fn, cb);
Lock is reentrant in the same domain
var domain = require('domain');
var lock = new AsyncLock({domainReentrant : true});
var d = domain.create();
d.run(function() {
lock.acquire('key', function() {
//Enter lock
return lock.acquire('key', function() {
//Enter same lock twice
});
});
});
// Specify timeout - max amount of time an item can remain in the queue before acquiring the lock
var lock = new AsyncLock({timeout: 5000});
lock.acquire(key, fn, function(err, ret) {
// timed out error will be returned here if lock not acquired in given time
});
// Specify max occupation time - max amount of time allowed between entering the queue and completing execution
var lock = new AsyncLock({maxOccupationTime: 3000});
lock.acquire(key, fn, function(err, ret) {
// occupation time exceeded error will be returned here if job not completed in given time
});
// Specify max execution time - max amount of time allowed between acquiring the lock and completing execution
var lock = new AsyncLock({maxExecutionTime: 3000});
lock.acquire(key, fn, function(err, ret) {
// execution time exceeded error will be returned here if job not completed in given time
});
// Set max pending tasks - max number of tasks allowed in the queue at a time
var lock = new AsyncLock({maxPending: 1000});
lock.acquire(key, fn, function(err, ret) {
// Handle too much pending error
})
// Whether there is any running or pending async function
lock.isBusy();
// Use your own promise library instead of the global Promise variable
var lock = new AsyncLock({Promise: require('bluebird')}); // Bluebird
var lock = new AsyncLock({Promise: require('q')}); // Q
// Add a task to the front of the queue waiting for a given lock
lock.acquire(key, fn1, cb); // runs immediately
lock.acquire(key, fn2, cb); // added to queue
lock.acquire(key, priorityFn, cb, {skipQueue: true}); // jumps queue and runs before fn2
See Changelog
See issue tracker.
MIT, see LICENSE