DEV Community

Cover image for Why Check Then Act Is the Silent Killer in Banking Backends, and How NestJS Prevents It
Peace Melodi
Peace Melodi

Posted on

Why Check Then Act Is the Silent Killer in Banking Backends, and How NestJS Prevents It

Picture a customer with fifty dollars left in their account. They open two tabs on their banking app by accident, or their phone retries a stuck request without them noticing, and two withdrawal requests for forty dollars each land on your server within a few milliseconds of each other. Both requests check the balance. Both see fifty dollars. Both get approved. The account is now negative ten dollars, and nobody wrote a single line of obviously broken code to cause it.

This is the check then act problem, and it is one of the most common ways banking backends quietly break under real, concurrent load. It rarely shows up in testing, since testing usually happens one request at a time. It shows up in production, under real traffic, exactly when it is hardest to catch and most expensive to get wrong.

Why this bug is so easy to miss

The logic looks completely correct when you read it top to bottom. Check the balance, confirm there is enough money, then subtract the amount. Nothing about that sequence looks dangerous on its own.

@Injectable()
export class AccountService {
  async withdraw(accountId: string, amount: number): Promise<void> {
    const account = await this.accountRepo.findOne({ where: { id: accountId } });

    if (account.balance < amount) {
      throw new BadRequestException('Insufficient funds');
    }

    account.balance -= amount;
    await this.accountRepo.save(account);
  }
}
Enter fullscreen mode Exit fullscreen mode

The problem is not the logic itself, it is the gap in time between the check and the act. If two requests both read the balance before either one writes back the new value, both requests are working with information that is already out of date by the time they act on it. The database has no idea these two requests are related, so it simply lets both writes happen.

The fix, atomic updates

The most direct fix is to stop reading the balance and writing it back as two separate steps, and instead let the database itself perform the check and the update in a single atomic operation.

@Injectable()
export class AccountService {
  constructor(private readonly dataSource: DataSource) {}

  async withdraw(accountId: string, amount: number): Promise<void> {
    const result = await this.dataSource.query(
      `UPDATE account SET balance = balance - $1
       WHERE id = $2 AND balance >= $1
       RETURNING id`,
      [amount, accountId],
    );

    if (result.length === 0) {
      throw new BadRequestException('Insufficient funds');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This version never reads the balance into your application code at all. The database checks the condition and performs the subtraction as one indivisible step, so there is no gap in time for a second request to slip through. Either the row has enough balance and the update happens, or it does not and nothing changes.

The fix, row locking

Sometimes the operation is more complex than a single column update, maybe you need to check the balance, apply a fee, log an entry, and update the balance, all as one coordinated unit. In that case, a row lock inside a transaction achieves the same safety, by making any other request that wants to touch the same row wait until the current one finishes.

@Injectable()
export class AccountService {
  constructor(private readonly dataSource: DataSource) {}

  async withdrawWithFee(accountId: string, amount: number, fee: number): Promise<void> {
    await this.dataSource.transaction(async (manager) => {
      const account = await manager.findOne(Account, {
        where: { id: accountId },
        lock: { mode: 'pessimistic_write' },
      });

      const total = amount + fee;
      if (account.balance < total) {
        throw new BadRequestException('Insufficient funds');
      }

      account.balance -= total;
      await manager.save(account);

      await manager.save(TransactionLog, {
        accountId,
        amount,
        fee,
        type: 'withdrawal',
      });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Once the row is locked, any second request trying to withdraw from the same account has to wait until the first transaction commits or rolls back. There is no window left for two requests to both see the same stale balance.

Choosing between the two, and knowing the tradeoff

The atomic update is usually the better choice when the operation is simple, since it never holds a lock at all and scales cleanly even under heavy load. Row locking is worth reaching for when the operation genuinely needs multiple coordinated steps inside one transaction.

It is worth being honest about the cost of row locking though. It solves correctness, but it can become its own bottleneck the moment a lot of demand concentrates on the same row, a single popular account or a shared balance being hit by many requests at once, since every one of those requests now has to wait in line behind the one currently holding the lock. If that kind of hot row contention is a realistic scenario for your system, the atomic update pattern, or reserving the operation in something like Redis and reconciling to the database afterward, tends to hold up better under that specific kind of pressure. The right choice depends on how concentrated your real traffic actually gets, not just on which pattern looks cleaner in isolation.

The bigger picture

NestJS does not prevent this bug automatically just by using the framework. What it gives you is a clean place to put the fix, a dedicated service method, a single transaction boundary, and a repository layer that makes both the atomic update and the row locking approach straightforward to implement correctly and to test under simulated concurrent load.

The habit worth building here is to ask, for any operation that checks a value before changing it, what happens if two of these run at exactly the same moment. In a to do list app, the answer might not matter much. In a banking backend, that gap is exactly where real money quietly disappears or, worse, gets created out of nowhere.

If you are building financial systems and want operations like this handled safely under real concurrent load from the start, this is exactly the kind of problem worth solving early, before it shows up as a support ticket instead of a code review comment.

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

Collapse
 
johnfrandsen profile image
John Frandsen

The check-then-act problem extends past your own balance table into the external data boundary, and the same fixes apply there.

When you sync transaction data from a bank API (PSD2, open banking), the sync job has the same shape: read the cursor, fetch new transactions, advance the cursor. Two concurrent sync runs can both read the same cursor position, both pull the same batch, and both write it — the exact gap you're describing, just one layer out.

Your atomic UPDATE pattern works identically for cursor advancement. Instead of WHERE balance >= amount, you get WHERE cursor = $expected — and if two jobs race to advance from the same position, only one gets a non-empty RETURNING set. The loser retries or skips.

This connects to the dedup point too. The lastSeenAt / providerVersion approach for handling pending-to-booked transaction mutations is really the atomic update applied to "where am I in the source data" instead of "what's the balance." Same bug shape, same fix shape.

Collapse
 
peacemelodi profile image
Peace Melodi

John, I did not see this connection until you laid it out, but you are completely right, the sync job reading a cursor, fetching a batch, and advancing it is exactly the same shape as reading a balance, checking it, and writing it back. Same gap, same risk, just applied to a different piece of state.
The cursor version of the atomic update makes total sense too, checking WHERE cursor equals the expected value and only advancing if that matches means two concurrent sync runs can no longer both act on the same stale position, the same way two withdrawals can no longer both act on the same stale balance.
And tying this back to lastSeenAt is a good way to put it, that pattern really is the same atomic update idea, just applied to figuring out where you are in the source data rather than what the balance is. Appreciate you connecting these across the two articles, it makes the underlying pattern much clearer than treating them as separate problems.

Collapse
 
johnfrandsen profile image
John Frandsen

Exactly right — and the same invariant shows up a third time in transaction ingestion: an idempotency key is really just "only apply if this external event id isn't present yet," a conditional write keyed on uniqueness instead of comparison. That's why a UNIQUE constraint on the external id is more than a convenience — it closes the gap at the database level, the same way WHERE cursor = :expected does for the sync job. Zoom out and every boundary where your system reflects external state has this same shape: a read, a decision, and a write that must be atomic, or the decision goes stale between the two. The event-sourcing crowd already recognizes the cursor as an offset, which is exactly why Kafka consumer commits are guarded with a generation/epoch check rather than a blind overwrite — same lost-update risk, same fix.