Async/await simplifies asynchronous code but can lead to silent data corruption if shared state is accessed concurrently without coordination. This article demonstrates how to identify and fix race conditions using mutex locks and atomic database operations.

Async/await makes asynchronous code look synchronous, which is convenient until it isn’t. You might write a clean-looking function that fetches user data and updates their profile, only to find out later that two simultaneous requests corrupted the state because they ran concurrently without coordination. This article explains how to identify and fix nodejs race conditions using practical patterns you can apply immediately.
After reading this, you will be able to:
- Identify when concurrent async operations interfere with shared mutable state.
- Implement robust locking mechanisms for database or cache updates.
- Structure your code to prevent data corruption in high-concurrency scenarios.
Prerequisites
- Node.js 18+ (for stable ESM support and modern async features)
- Basic understanding of Promises and the event loop
- Conceptual knowledge: You should understand that JavaScript is single-threaded but non-blocking, meaning multiple callbacks can be queued while waiting for I/O
The Solution
We’ll build a simple user profile updater. In a real application, this might be an Express route handler. The goal is to increment a “login count” every time a user logs in.
Step 1: The Naive Approach (Broken)
First, let’s look at the code that causes problems. You have two simultaneous requests for the same user ID. Both read the current login count, add one, and write it back. If they run concurrently, you lose an update.
// Simulating a database call
async function getUser(userId) {
return { id: userId, loginCount: 5 };
}
async function updateUser(userId, newCount) {
console.log(`Saving count ${newCount} for user ${userId}`);
// Simulate DB latency
await new Promise(res => setTimeout(res, 100));
return { id: userId, loginCount: newCount };
}
// The buggy handler
async function handleLogin(userId) {
const user = await getUser(userId);
// RACE CONDITION HERE: Two requests might read '5' simultaneously
const updatedUser = { ...user, loginCount: user.loginCount + 1 };
return updateUser(userId, updatedUser.loginCount);
}
// Simulating two concurrent requests
Promise.all([
handleLogin(1),
handleLogin(1)
]).then(() => console.log('Done'));
Why this fails: Request A reads count 5. Request B reads count 5. Both calculate 6. Request A saves 6. Request B saves 6. The login count should be 7, but it’s stuck at 6. This is a classic nodejs race condition.
Step 2: Adding Concurrency Control with Mutex
To fix this, you need to ensure only one operation modifies the user’s data at a time. We can implement a simple mutex (mutual exclusion) lock.
class Mutex {
constructor() {
this._queue = [];
this._locked = false;
}
// Acquires the lock, returning a release function
async acquire() {
if (!this._locked) {
this._locked = true;
return () => {
this._locked = false;
// Release next waiter in queue
const next = this._queue.shift();
if (next) next.resolve();
};
} else {
// Queue up waiting for release
return new Promise((resolve) => {
this._queue.push({ resolve });
});
}
}
}
const userLock = new Mutex();
async function handleLoginSafe(userId) {
// Acquire lock before reading
const release = await userLock.acquire();
try {
const user = await getUser(userId);
const updatedUser = { ...user, loginCount: user.loginCount + 1 };
// Critical section: Write happens while lock is held
return updateUser(userId, updatedUser.loginCount);
} finally {
// Always release the lock
release();
}
}
// Run same simulation
Promise.all([
handleLoginSafe(1),
handleLoginSafe(1)
]).then(() => console.log('Done'));
Expected Output: You will see logs indicating sequential saving. The final count will correctly be 7.
Alternative: Database-Level Locking
If you are using a relational database like PostgreSQL, you might not need an in-memory mutex. Instead, use atomic operations or row-level locks:
-- Atomic increment (no race condition possible)
UPDATE users SET login_count = login_count + 1 WHERE id = ?;
-- Or explicit locking
SELECT * FROM users WHERE id = ? FOR UPDATE;
Tradeoff: In-memory mutexes are fast but don’t scale across multiple server instances. Database locks scale globally but add latency and complexity to transaction management. Choose based on your architecture.
How It Works
The core issue is that await pauses execution of that specific function, but it doesn’t pause other concurrent calls to the same function. When you have shared state (like a database row or an in-memory object), you must serialize access to that state.
Think of a mutex like a bathroom key in a small office. Only one person can hold the key and use the bathroom at a time. If someone else arrives, they wait outside until the door opens again. In code, acquire() is asking for the key, and release() is giving it back. Without this pattern, multiple people (async requests) try to enter at once, leading to chaos (data corruption).
For nodejs race conditions, the problem isn’t concurrency itself—it’s uncoordinated access to shared resources. The event loop allows many things to happen “at once,” but your business logic often requires strict ordering for specific operations.
Common Pitfalls
- Assuming
async/awaitis synchronous: Just because you useawaitdoesn’t mean other code isn’t running. Always ask: “Can another request hit this same endpoint while I’m waiting?” If yes, you need locking or atomic operations. - Forgetting to release the lock: Using
try...finallyis critical. If an error occurs inside the critical section and you don’t have afinallyblock, the lock stays held forever, causing deadlocks for all future requests. - Locking too broadly: Don’t wrap entire route handlers in a global mutex unless necessary. Lock only the specific resource (e.g., user ID) that needs protection. This allows other users to log in concurrently without blocking your system.
Wrapping Up
You’ve learned why blind async/await usage can lead to silent data corruption and how to fix it using mutual exclusion or atomic database operations. The key takeaway: concurrency is powerful, but shared state requires coordination.
Next Steps:
- Experiment with the
async-mutexnpm package for production-ready locking patterns. - Study optimistic concurrency control (using version numbers) as an alternative to locking for high-read, low-write scenarios.
Start by auditing your handlers that read and then write data. Add a lock or use an atomic update query. Your database will thank you.



