DEV Community

Cover image for I Built a NestJS Banking Ledger That Cannot Be Overdrawn, Even Under Concurrent Requests
Peace Melodi
Peace Melodi

Posted on

I Built a NestJS Banking Ledger That Cannot Be Overdrawn, Even Under Concurrent Requests

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);
});
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 (2)

Collapse
 
johnfrandsen profile image
John Frandsen

This is a really clean walkthrough of the lost update problem. The SELECT FOR UPDATE inside a transaction is the right call, and the honesty about the 500 error and lock scope is exactly the kind of thing that only surfaces when you actually build it.
One dimension worth adding: the lost update problem gets a second wringle when your ledger has to reconcile against external bank data arriving asynchronously. In that scenario, the "two writers" aren't two API requests from your own system — they're your internal balance update and a bank sync job that lands a batch of transactions and a new running balance. The bank's running balance is an independent consistency checkpoint, and if your processWithdrawal and syncBankTransactions don't coordinate on the same row lock, you get the exact race this article describes, but across different code paths that don't obviously share anything.
A pattern that works well in practice is layering an idempotency key on transaction ingestion alongside the row lock: a UNIQUE(account_id, provider_transaction_id) constraint means the bank sync job can safely attempt to INSERT the same transaction twice (retries, cursor overlap, pending‖booked transitions) and the database rejects the duplicate before it ever touches the balance. The FOR UPDATE lock then only needs to guard the actual balance decrement, not the ingestion itself. This separation keeps the lock window tight — you're not holding a pessimistic write lock across a batch of 50 transactions, just across the single UPDATE accounts SET balance = balance - ? statement.
The pending‖booked transition is the trickiest case: the bank first reports a transaction as PENDING and later reports it again as BOOKED with the same transaction_id but a different booking_date. If your idempotency key is just transaction_id, the second insert fails and you're stuck with a pending transaction that never completes. Including the status in the uniqueness check (or using an UPSERT keyed on transaction_id that transitions the row) is the practical fix.
The honesty about pessimistic_write lock scope is the most useful part of this post — most concurrency tutorials stop at "add a lock" and never mention that holding it too long turns your throughput into a serial queue.

Collapse
 
mickyarun profile image
arun rajkumar

The lock is correct, and your ordering explanation is the best part — "whoever grabs the lock first, not whoever the client sent first" is the thing most people get wrong. Where this design hits a wall is a single hot account. pessimistic_write serialises every transfer touching that row, so an account taking a burst of writes funnels all of them through one lock while the rest of the DB sits idle, and throughput on that one account falls off a cliff. The escape we lean on in payments is to stop locking a mutable balance at all: make the ledger append-only, INSERT an immutable entry per movement, and derive the balance by summing entries (or from a periodically materialised snapshot). There's no single row to contend on, and you get the audit trail for free — which in fintech you needed anyway. When you still want a hard "cannot overdraw" guarantee on that model, a conditional insert that checks the derived balance enforces it without holding a long row lock. Optimistic locking with a version column plus a retry is the lighter middle ground when real contention is rare.