Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Race Conditions Explained With a Bank Account: Where the Missing Money Went

About Post

An account has 100 in it. Two withdrawals arrive at the same moment: one for 30, one for 50. Both succeed. The final balance is 50.

Not 20. Fifty. Thirty just evaporated, and every line of code involved is "correct". Each request read the balance, checked there was enough, subtracted, and saved. That's a race condition, and it's one of those bugs that passes every test you write, because your tests run one request at a time and your users don't.

What actually happened

Here's the timeline. Each row is a moment; the two columns are two PHP workers handling two requests in parallel:

TimeRequest A (withdraw 30)Request B (withdraw 50)
1Reads balance: 100
2Reads balance: 100
3100 ≥ 30, OK. Saves 70
4100 ≥ 50, OK. Saves 50

Request B made its decision using a value that was already out of date. Its write silently replaced A's. This is called a lost update, and it's the most common race condition in web apps.

The code that does it looks completely innocent:

$account = Account::findOrFail($id);

if ($account->balance < $amount) {
    throw new InsufficientFunds();
}

$account->balance = $account->balance - $amount;
$account->save();

The gap between "read" and "write" is a few milliseconds. On a quiet test server that window almost never gets hit. On a busy production server, or with a user double-tapping a button on a slow connection, it will be.

And no, wrapping it in a transaction doesn't fix it on its own. With MySQL's default isolation level, a plain SELECT inside a transaction still reads without locking, so both requests see 100.

Fix 1: let the database do the maths

The simplest fix is to stop reading the value into PHP at all. Put the check and the change in a single SQL statement, which the database runs atomically:

$updated = Account::whereKey($id)
    ->where('balance', '>=', $amount)
    ->decrement('balance', $amount);

if ($updated === 0) {
    throw new InsufficientFunds();
}

That runs UPDATE accounts SET balance = balance - 30 WHERE id = ? AND balance >= 30. The row is locked for the duration of the statement, so the second request sees the new balance. If there isn't enough money, zero rows change and you know about it.

This is my default for counters, stock levels, seat counts and balances. It's fast, it doesn't hold locks for long, and there's nothing to forget.

Fix 2: lock the row while you decide

Sometimes the logic is too rich for one UPDATE. You need to read the account, check a few rules, write a ledger entry and update the balance together. Then you want a pessimistic lock: "I'm working on this row, everyone else waits."

DB::transaction(function () use ($id, $amount) {
    $account = Account::whereKey($id)->lockForUpdate()->firstOrFail();

    if ($account->balance < $amount) {
        throw new InsufficientFunds();
    }

    $account->decrement('balance', $amount);

    $account->ledgerEntries()->create([
        'type'   => 'withdrawal',
        'amount' => $amount,
    ]);
});

lockForUpdate() adds FOR UPDATE to the query. Request B now blocks at that line until A's transaction commits, then reads the fresh balance of 70. Two rules make or break this:

  • The lock only works inside a transaction. Outside one, it's released the moment the query finishes.
  • Keep the transaction short. No HTTP calls to a payment gateway, no emails, no PDF generation inside it. Everyone else waiting for that row is waiting for your slowest line.

When you lock more than one row, as in a transfer between two accounts, always lock them in the same order (for example, lowest id first). Otherwise A locks account 1 and waits for 2, while B locks 2 and waits for 1, and the database has to kill one of them as a deadlock. Laravel can retry for you: DB::transaction($callback, 3) re-runs the closure up to three times on a deadlock.

Fix 3: optimistic locking for long edits

Locks are wrong for things like a staff member editing a contract in a form for ten minutes. You can't hold a database lock while someone goes to get coffee.

Here, add a version column. Save with "update this row only if the version is still the one I loaded", and bump it:

$updated = Contract::whereKey($contract->id)
    ->where('version', $request->integer('version'))
    ->update([...$request->validated(), 'version' => DB::raw('version + 1')]);

if ($updated === 0) {
    // Someone else saved first: reload and show them the changes
}

Nobody waits, and nobody silently overwrites a colleague's work.

Fix 4: unique constraints for "check, then insert"

Another family of races doesn't update anything. It creates duplicates. "Does this user already have a booking for this slot? No? Insert one." Two requests both see "no" and both insert.

The fix is a unique index on the columns that define "duplicate", plus handling the error:

try {
    Booking::create(['unit_id' => $unitId, 'slot' => $slot, 'user_id' => $userId]);
} catch (UniqueConstraintViolationException) {
    return back()->withErrors(['slot' => 'That slot was just taken.']);
}

Your application check is for a friendly message. The unique index is the actual guarantee, because the database enforces it even when two inserts arrive in the same millisecond.

And when the shared thing isn't a row?

If two workers must not run the same job at once, like generating this month's invoices, a cache-based atomic lock does the job: Cache::lock('invoices:'.$month, 120)->get(fn () => $this->generateInvoices($month)). It needs a cache driver that supports atomic locks, such as Redis or the database driver.

The one-line test: if your code reads a value, makes a decision, and then writes based on that decision, ask "what if another request does the same thing in between?" If the answer is bad, make the read-decide-write atomic: one SQL statement, a row lock, a version check, or a unique constraint.

How to see it with your own eyes

Race conditions are easier to believe once you've caused one. Fire a burst of parallel requests at a local endpoint:

seq 20 | xargs -P 20 -I{} curl -s -X POST http://localhost:8000/api/withdraw -d amount=30

Run it against the naive version and against Fix 1, and compare the final balances. It's a five-minute experiment that changes how you read code.

Cheat sheet

  • Counters, balances, stock: atomic UPDATE ... WHERE.
  • Multi-step logic on a row: lockForUpdate() in a short transaction.
  • Long human edits: optimistic locking with a version column.
  • Duplicates: a unique index, and catch the exception.
  • Jobs and non-database resources: atomic cache locks.

Where did a race condition first get you: money, stock, bookings, or something stranger?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close