Modern backend applications handle thousands or even millions of requests every second. Users perform actions simultaneously: buying products, transferring money, updating profiles, sending messages, and more.
But what happens when two requests try to modify the same data at the same time?
This is where race conditions appear — one of the most subtle and dangerous problems in backend development.
A race condition can cause incorrect data, security issues, financial losses, and unpredictable application behavior. Understanding how race conditions happen and how to prevent them is an essential skill for backend developers.
What Is a Race Condition?
A race condition occurs when multiple processes or requests access and modify shared data at the same time, and the final result depends on the order in which those operations execute.
The problem is that the developer expects operations to happen in a specific sequence, but the computer executes them based on timing, network delays, database speed, and system load.
Simple Example: Bank Account Withdrawal
Imagine a user has:
Account Balance: $100
Enter fullscreen mode Exit fullscreen mode
Two withdrawal requests arrive at the same time:
Request A: Withdraw $80
Request B: Withdraw $50
Enter fullscreen mode Exit fullscreen mode
The backend checks the balance:
Request A:
Balance >= 80? Yes
Request B:
Balance >= 50? Yes
Enter fullscreen mode Exit fullscreen mode
Both requests continue because they saw the original balance of $100.
The system processes:
$100 - $80 = $20
$100 - $50 = $50
Enter fullscreen mode Exit fullscreen mode
The final balance might become:
$50
Enter fullscreen mode Exit fullscreen mode
instead of:
-$30 (which should have been rejected)
Enter fullscreen mode Exit fullscreen mode
The application has allowed money to be withdrawn that does not exist.
This is a race condition.
How Race Conditions Happen in Express.js
Express.js applications are often built around asynchronous operations:
- Database queries
- API calls
- File operations
- Background jobs
- Message queues
Consider this simple inventory system:
app.post("/purchase", async (req, res) => {
const product = await Product.findById(req.body.productId);
if (product.stock > 0) {
product.stock -= 1;
await product.save();
res.json({
message: "Purchase successful"
});
}
});
Enter fullscreen mode Exit fullscreen mode
At first glance, this looks correct.
But imagine the product has:
stock: 1
Enter fullscreen mode Exit fullscreen mode
Two users click “Buy” at the same time.
Request A reads stock = 1. Request B reads stock = 1. Both pass the if (product.stock > 0) check, and both reduce the stock from 1 to 0.
The product has been sold twice.
Common Places Where Race Conditions Occur
1. Payment Processing
- Double charging customers
- Duplicate transactions
- Incorrect wallet balances
2. Inventory Management
- Selling unavailable products
- Negative stock values
- Overselling limited items
3. User Accounts
- Duplicate usernames
- Password reset conflicts
- Multiple account updates overwriting each other
4. Likes, Views, and Counters
A post has:
likes: 100
Enter fullscreen mode Exit fullscreen mode
Two users like the post at the same time. Request A computes likes = likes + 1, and Request B computes likes = likes + 1, both starting from the same value of 100.
Expected result: 102 likes. Possible actual result: 101 likes.
Solutions for Race Conditions in Express.js
There is no single solution. The correct approach depends on the type of operation — and each option below comes with a trade-off in complexity, latency, or throughput, not just a fix.
1. Database Transactions
A transaction groups multiple operations into one atomic operation. Either everything succeeds, or nothing happens.
Example using MongoDB transactions:
const session = await mongoose.startSession();
try {
session.startTransaction();
const product = await Product.findById(productId).session(session);
if (product.stock <= 0) {
throw new Error("Out of stock");
}
product.stock -= 1;
await product.save({ session });
await session.commitTransaction();
res.json({ message: "Purchase successful" });
} catch (error) {
await session.abortTransaction();
res.status(409).json({ message: error.message });
} finally {
session.endSession();
}
Enter fullscreen mode Exit fullscreen mode
Note the catch block now sends an actual error response to the client instead of silently swallowing the failure — if you abort a transaction without telling the caller, they have no way to know the purchase didn’t go through.
Trade-off: transactions are the most correctness-guaranteeing option, but they hold locks and coordinate across replicas, which adds latency and can hurt throughput under heavy contention. Reserve them for operations that genuinely span multiple documents/collections.
2. Atomic Database Operations
Instead of:
const product = await Product.findById(id);
product.stock--;
await product.save();
Enter fullscreen mode Exit fullscreen mode
Use an atomic update:
const product = await Product.findOneAndUpdate(
{ _id: id, stock: { $gt: 0 } },
{ $inc: { stock: -1 } },
{ new: true }
);
if (!product) {
return res.status(409).json({ message: "Out of stock" });
}
Enter fullscreen mode Exit fullscreen mode
MongoDB performs the check and update as a single operation. Only one request can successfully reduce the stock, and there’s no separate read step for another request to race against.
Trade-off: this is usually the cheapest and fastest fix, and should be your default choice for simple counter/decrement-style updates. It doesn’t help when a single logical operation needs to touch multiple documents consistently — that’s what transactions are for.
3. Using Locks
A lock prevents multiple requests from accessing the same resource simultaneously.
Important limitation: an in-process lock like
async-mutexonly works within a single Node.js process. If your app runs multiple instances (multiple containers, multiple PM2 workers, horizontal scaling behind a load balancer — which is normal for anything in production), each instance has its own separate lock, and two requests hitting two different instances can still race. Use this only for single-instance apps or for coordinating within one process; for anything running on more than one server, skip to the Redis distributed lock section below.
Example using a mutex, scoped per-resource rather than globally:
const { Mutex } = require("async-mutex");
// One mutex per account, not one mutex for the whole app —
// a single global mutex would serialize every user's withdrawal
// against every other user's, even when they don't conflict.
const accountLocks = new Map();
function getLockForAccount(accountId) {
if (!accountLocks.has(accountId)) {
accountLocks.set(accountId, new Mutex());
}
return accountLocks.get(accountId);
}
app.post("/withdraw", async (req, res) => {
const lock = getLockForAccount(req.user.id);
const release = await lock.acquire();
try {
const account = await Account.findById(req.user.id);
if (account.balance < req.body.amount) {
return res.status(409).json({ message: "Insufficient funds" });
}
account.balance -= req.body.amount;
await account.save();
res.json({ message: "Withdrawal successful" });
} finally {
release();
// optional: clean up the map entry once no one is waiting on it,
// to avoid unbounded growth for accounts that are only ever used once
}
});
Enter fullscreen mode Exit fullscreen mode
Locking per account means two different users can withdraw money concurrently without waiting on each other, while requests against the same account are still serialized safely.
Trade-off: even scoped correctly, this only protects a single Node process. It’s a reasonable stopgap for low-traffic or single-instance services, but don’t treat it as a production-grade solution for anything horizontally scaled.
4. Optimistic Locking
Optimistic locking assumes conflicts are rare. The database stores a version number, and a write only succeeds if the version hasn’t changed since it was read.
async function withdrawWithRetry(userId, amount, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const account = await Account.findById(userId);
if (account.balance < amount) {
throw new Error("Insufficient funds");
}
const result = await Account.updateOne(
{ _id: userId, version: account.version },
{
$set: { balance: account.balance - amount },
$inc: { version: 1 }
}
);
if (result.modifiedCount === 1) {
return; // success
}
// version had already changed — someone else updated first.
// loop again and retry against the fresh state.
}
throw new Error("Could not complete withdrawal, please try again");
}
Enter fullscreen mode Exit fullscreen mode
If another request already changed version between the read and the write, modifiedCount is 0, and the loop retries against the current state instead of silently failing or corrupting data.
Trade-off: great for low-contention scenarios (most updates don’t conflict), but under high contention on the same record, retries pile up and add latency. Not a good fit for hot resources — use an atomic update or a lock there instead.
5. Redis Distributed Locks
For applications running multiple server instances, in-process locks are not enough — each instance has its own memory, so a lock held in Server A’s memory is invisible to Server B.
A Redis lock lives outside any single server, so all instances can coordinate against it:
const lockKey = `product_${productId}_lock`;
const lockValue = crypto.randomUUID(); // unique per holder, so you only release your own lock
const acquired = await redis.set(lockKey, lockValue, "NX", "EX", 10);
if (acquired) {
try {
// safely update product
} finally {
// only delete if we still own the lock — a naive DEL can remove
// a lock that expired and was re-acquired by someone else
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await redis.eval(script, 1, lockKey, lockValue);
}
} else {
return res.status(409).json({ message: "Resource is busy, try again" });
}
Enter fullscreen mode Exit fullscreen mode
Redis is commonly used for:
- Payment processing
- Booking systems
- Limited-time sales
- Distributed jobs
Trade-off: distributed locks add a network hop and an external dependency (Redis becomes a single point of coordination, if not availability). For strict correctness guarantees under failure scenarios (Redis failover, clock drift), tools like Redlock exist but add further complexity — don’t reach for this unless your app genuinely runs on more than one instance.
6. Idempotency Keys
Sometimes the problem is not simultaneous updates but repeated requests.
A customer clicks “Pay Now.” The network fails. The frontend retries. Without protection, the customer is charged twice.
Solution: generate an idempotency key on the client, and have the server store the result keyed by it:
app.post("/pay", async (req, res) => {
const idempotencyKey = req.headers["idempotency-key"];
const existing = await PaymentRecord.findOne({ idempotencyKey });
if (existing) {
return res.json(existing.result);
}
const result = await processPayment(req.body);
await PaymentRecord.create({
idempotencyKey,
result,
createdAt: new Date() // pair with a TTL index so old keys expire
});
res.json(result);
});
Enter fullscreen mode Exit fullscreen mode
If another request arrives with the same key, the stored result is returned instead of charging the customer again.
Trade-off: you need a storage strategy for keys (a TTL index is typical, e.g. expire after 24 hours) so the collection doesn’t grow forever, and you need to decide how long a key stays valid for retries.
Best Practices to Prevent Race Conditions
Use Database Constraints
email: {
type: String,
unique: true
}
Enter fullscreen mode Exit fullscreen mode
Let the database enforce rules it can guarantee — application-level checks can race, but a unique index can’t be bypassed by concurrent writes.
Avoid Reading Then Writing
Bad:
user.balance++;
save();
Enter fullscreen mode Exit fullscreen mode
Better:
{ $inc: { balance: 1 } }
Enter fullscreen mode Exit fullscreen mode
Keep Critical Operations Small
Avoid holding a lock or transaction open across slow operations (network calls, waiting on external APIs). The longer the operation takes, the higher the chance of conflict — and the longer other requests are blocked waiting on it.
For SQL Databases
The examples above are MongoDB/Redis-focused, but the same principles apply to relational databases:
- Atomic updates:
UPDATE products SET stock = stock - 1 WHERE id = ? AND stock > 0 - Row-level locking:
SELECT * FROM accounts WHERE id = ? FOR UPDATEinside a transaction, which locks the row until the transaction commits or rolls back - Unique constraints and
CHECKconstraints for invariants the database can enforce directly
Test Concurrent Requests
A race condition may not appear during normal testing. It typically only shows up under real concurrent load, so functional tests that hit your endpoint one request at a time won’t catch it.
Use tools like:
- Apache Benchmark
- Artillery
- k6
- JMeter
Example:
ab -n 1000 -c 100 http://localhost:3000/purchase
Enter fullscreen mode Exit fullscreen mode
This sends 1000 requests with 100 of them in flight concurrently — a much closer approximation of real traffic spikes than sequential testing.
Conclusion
Race conditions are among the hardest backend problems because they often appear only under heavy traffic. An application can work perfectly during development and fail when thousands of users interact with it simultaneously.
When building Express.js applications:
- Prefer atomic database operations for simple counter-style updates — they’re cheap and race-free by construction
- Use transactions when an operation genuinely spans multiple documents or tables
- Apply locks for shared resources, scoped as narrowly as possible, and remember in-process locks don’t help across multiple server instances
- Use optimistic concurrency control with retry logic for low-contention updates
- Add idempotency keys for repeat requests caused by retries, not just simultaneous conflicting writes
- Let the database enforce the rules it can guarantee (uniqueness, row locks)
- Load-test concurrently, not just sequentially, since that’s the only way most of these bugs actually surface
A reliable backend is not only about handling requests quickly — it is about ensuring that every request produces the correct result, even when thousands of requests happen at the same time, and about choosing the cheapest tool that actually guarantees that correctness for the situation at hand.
답글 남기기