async function withdraw(accountId, amount) {
const account = await db.getAccount(accountId);
if (account.balance < amount) {
throw new Error("Insufficient funds");
}
await db.setBalance(accountId, account.balance - amount);
}
This is a check-then-act race. The code checks the balance, then acts on it, but the data can change between those two steps.
Each await gives control back to the event loop. So two requests can run in turns, mixed together:
- Both requests read a balance of 100.
- Both pass the check.
- Both write their result.
Two withdrawals of 80 succeed. The second write overwrites the first. This is called a lost update. One thread does not make a series of awaits atomic (done as one step that nothing can interrupt).
The fix: make the check and the write one atomic step at the source of truth, which is the database. Three ways:
- Use a conditional update, such as
UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1. Then check how many rows it changed. - Lock the row with
SELECT ... FOR UPDATEinside a transaction. - Use optimistic locking with a version column. The update only succeeds if the version has not changed since you read it.
An in-memory mutex (a lock in your Node process) does not work here. It only protects one process, so it breaks as soon as you run two instances.
- Each await gives control to the event loop, so requests can mix between the read and the write.
- Check-then-act on old data causes lost updates.
- One thread does not make a series of awaits atomic.
- Fix it with an atomic conditional UPDATE, row locks or optimistic locking.
- A lock inside one process fails when you run more than one instance.
Read more