Concurrency management in JavaScript: the mutex (mutual exclusion) lock pattern

Managing concurrency in any programming language is an important advanced topic, but it becomes especially relevant in JavaScript due to the lack of native multi-threaded locks. JavaScript is single threaded and code execution on the stack is sequential. However, async operations like promises yield control in the stack because they are microtasks and are handed asynchronously to the event loop. While the execution order of a promise callback is determinant once it is added to the event loop, the execution order becomes non-determinant in implementation because of the latency of an external call. Microtasks like promise callbacks ( .then(), .catch(), .finally() ) will not get pulled into the event loop for execution until the promise settles (resolves or fails), and this creates a concurrency problem in the form of a race condition in need of a design pattern.
Consider the following use case.
You are developing the front end for a banking app. A user is expecting to always see an accurate account balance when they deposit or withdraw funds. While the backend is the ultimate source of truth for the balance, the front end is responsible for requesting updates to the balance and displaying the results of these requests accurately to the user.
In a banking app without concurrency management, the following situation might occur.
An account holder opens their banking app and observes a balance of $100 in their checking account. They deposit an additional $100 from a check, expecting a new balance of $200. While the deposit is still in flight they try to move the anticipated balance of $200 to their savings account. Due to excessive latency at a data center the transfer request hits the database before the deposit request and they get an insufficient funds error as a result.
What we need is a way to manage the concurrency of the requests. There are a few different design patterns to handle this, including optimistic and pessimistic locking. There is also sequentialization via mutex locking, which is the design pattern that this article addresses.
Lets look at how we can handle the example above via the mutex pattern in TypeScript:
class AsyncLock {
private disableNext: Promise<void>;
constructor() {
this.disableNext = Promise.resolve();
}
// force async task to wait in line
async acquire(): Promise<() => void> {
let release!: () => void;
const currentLock = this.disableNext; // assign private reference to the most recent promise created
this.disableNext = new Promise<void>((resolve) => {
// create a new promise for the next request in queue
release = resolve;
});
await currentLock;
return release;
}
}
// how to use AsyncLock
const lock = new AsyncLock();
let balance = 100;
type TransactionType = "withdraw" | "deposit";
async function updateBalance(
amount: number,
transactionType: TransactionType
): Promise<void> {
const release = await lock.acquire(); // wait in line
try {
console.log(`Reading balance... Current: $${balance}`);
await new Promise((r) => setTimeout(r, 50)); // simulate database delay
if (transactionType === "withdraw" && balance >= amount) {
// withdraw
balance -= amount;
console.log(`Withdraw: $${amount}. Current: $${balance}`);
} else if (transactionType === "deposit") {
// deposit
balance += amount;
console.log(`Deposit: $${amount}. Current: $${balance}`);
} else {
console.log("NOP");
}
} finally {
release(); // let the next task run by calling the resolve method on the current promise
}
}
updateBalance(100, "deposit"); // deposit $100 into checking
updateBalance(200, "withdraw"); // transfer (withdraw) $200 to savings
Let’s break down the example.
When you execute new AsyncLock() , JavaScript creates a brand new locking object. this.disableNext = Promise.resolve(); initializes with a resolved promise on creation, allowing the first request in the queue to process immediately. Every time a function calls lock.acquire() it needs to know what the current state of the lock chain is. this.disableNext allows the code to look at the shared state stored on that specific lock instance. It reads the previous promise ( currentLock ) and then overwrites this.disableNext with a new pending promise. Because this points to the persistent lock instance, the next function that calls lock.acquire() will see that updated promise and wait in line behind it. When the current process completes it will call release() which will resolve the current (in flight) promise and allow the next request in line to proceed.
The result is a lock that queues the requests in a predictable and controllable order. A follow-up request can never supersede the previous and your UI will remain synced with the resolving request order. Any number of concurrent requests can be queued and will process First In — First Out (FIFO). The use of a JavaScript Class and the this keyword ensures that the disableNext state is shared across instances. That makes the mutex design pattern an excellent choice for managing concurrency across shared states in JavaScript applications where high performance is necessary and deadlocking additional requests must be avoided.





