Imagine an account holding one hundred dollars. Two withdrawal requests for sixty dollars each arrive at almost exactly the same moment. Both requests check the balance. Both see one hundred dollars. Both get approved. The account is now negative twenty dollars, and there is no error anywhere in the logs to explain why.
This is called the lost update anomaly, and it is one of the most dangerous bugs a financial backend can have, precisely because it never announces itself. Nothing crashes. Nothing throws. The ledger simply becomes wrong, quietly, and stays wrong until someone reconciles it against reality and finds a gap that should never have existed.
I wanted to prove I could actually solve this properly, not just explain it in theory, so I built a small NestJS project around this exact problem. The repository is on GitHub, and this article walks through what the problem actually looks like, how I solved it, and the mistakes I ran into along the way.
Repository: https://github.com/PeaceMelodi/mini-banking-ledger
Why the bug is invisible at the application level
The dangerous part of this bug is that the code checking the balance looks completely correct on its own. Read the balance, confirm there is enough money, subtract the amount, save it. Nothing about that sequence looks wrong when you read it top to bottom.
The problem is the gap in time between the read and the write. If two requests both read the balance before either one writes back, both are working with information that is already stale by the time they act on it. The database has no idea these two requests are related, so it lets both writes happen, and the second write does not add to the first, it simply overwrites it.
The fix, forcing the database to enforce order
The actual fix is not smarter application code, it is refusing to let the application coordinate this at all, and instead forcing PostgreSQL itself to serialize access to the specific row being changed. This is done with a pessimistic write lock during the account lookup, inside a database transaction.
await this.dataSource.transaction(async (manager) => {
const account = await manager.findOne(Account, {
where: { id: accountId },
lock: { mode: 'pessimistic_write' },
});
if (!account) {
throw new NotFoundException('Account not found');
}
if (account.balance < amount) {
throw new BadRequestException('Insufficient funds');
}
account.balance -= amount;
await manager.save(account);
});
Setting the lock mode to pessimistic_write tells TypeORM to generate a SELECT FOR UPDATE query instead of a plain SELECT. A plain SELECT lets any number of concurrent requests read the same row at the same time with zero coordination. A SELECT FOR UPDATE acquires an exclusive lock on that row at the database engine itself, so from the moment one transaction reads it with intent to modify it, every other transaction trying to do the same is physically forced to wait.
Once the first transaction commits, the second one finally proceeds, but it now reads the updated balance, not the original one. That is what makes the whole system self correcting. No request is ever allowed to act on stale data, because the database will not let it read until it is entitled to current data.
What actually went wrong while building this
I think the mistakes are worth sharing honestly, since they are the kind of thing that only shows up once you actually try to build something rather than just describe it.
The first failure was a plain five hundred error the moment I added the lock. I had written a raw throw new Error for the insufficient funds case, and NestJS treats anything it does not recognize as part of its own exception system as an unhandled error, hiding the real reason behind a generic response. The fix was switching to NestJS's own exception classes.
if (account.balance < amount) {
throw new BadRequestException('Insufficient funds');
}
if (!account) {
throw new NotFoundException('Account not found');
}
These are understood natively by NestJS's exception pipeline, so they return clean, structured error responses with the correct status codes instead of a vague failure that tells the client nothing useful.
The second failure happened after wiping the database with docker compose down using the volumes flag, to get a clean state for testing. My race condition test script started failing with a four hundred and four error, even though nothing about the account logic had changed. It turned out wiping the volume also wiped the seeded accounts, and the seeding logic generated brand new ids on restart, while my test script was still pointing at the old ones. The fix was simply querying the accounts endpoint fresh after any reset, rather than assuming previously seeded ids would still be valid.
Proving it actually works, and a surprise in the results
With both fixes in place, I ran the real test, firing two transfer requests at the same account at the same time using Promise all.
npm run test:race
The result was correct, but not in the order I expected. The second request in my array came back first, with a successful transfer. The first request in my array came back second, with a Bad Request and an Insufficient Funds message. At first this looked backwards, but it is actually the correct behavior, and understanding why meant looking past the application code entirely.
Promise all fires both requests at what looks, from the code's perspective, like the exact same instant. But once those requests leave the process, they become independent network events, subject to tiny timing differences at the operating system and socket level. There is no guarantee they arrive at the server in the order they were sent. In this run, the second request in my array simply happened to physically reach the server first, acquired the lock, read the original one hundred dollar balance, and committed a new balance of forty dollars. The first request in my array arrived a fraction of a millisecond later, waited for the lock, and when it finally proceeded, it saw the already updated forty dollar balance, correctly decided that was not enough to cover its own withdrawal, and correctly failed.
That is the entire point of the architecture. It does not matter which request the client thinks was sent first. What matters is that whichever request acquires the lock first always acts on accurate data, and every request after it is always evaluated against the true, current state of the account, never a stale snapshot.
The bigger picture
This project is small on purpose. It is not a full banking platform, and it does not need to be, to prove the point. What it demonstrates is that correctness under concurrent writes is not something you get from careful application logic alone, it comes from deliberate decisions at the persistence layer, and from actually testing under real, imperfect network timing instead of assuming your logic is correct because it reads correctly on paper.
If you want to see the full implementation, including the exception handling, the Docker setup, and the race test script itself, the repository is linked above, and the README walks through the entire debugging process in more depth than this article does.
I am Peace Melodi, a backend software engineer. If you want your business to scale big, comfortably handling millions of users without breaking, with strong scalability and security in place, feel free to reach out.
LinkedIn: https://www.linkedin.com/in/melodi-peace-406494368
GitHub: https://github.com/PeaceMelodi
Top comments (0)