Back to Blog
System Design

Race Conditions in Financial Systems: How Users Withdraw More Than Their Balance

January 17, 2026 2 min read 983 views

Background

This issue occurred in a real production system handling wallet balances and withdrawals.
A few users managed to withdraw more money than they actually had in their account.

At first glance, this seemed impossible — but it was very real.

The Root Cause: Race Condition

The problem happened when multiple withdrawal requests were sent at the same time.

Typical Vulnerable Flow

  1. Fetch current balance
  2. Check if balance >= withdrawal amount
  3. Deduct balance
  4. Save updated balance

When multiple requests hit the server simultaneously, they all:

  • Read the same balance
  • Passed validation
  • Updated the balance independently

This caused over-withdrawals.

Why This Happens

Databases are fast, but requests are faster in parallel systems.

Without proper locking:

  • Each request works with stale data
  • Validation becomes meaningless
  • Financial integrity breaks

This is extremely common in:

  • Wallet systems
  • Betting platforms
  • Payment gateways
  • E-commerce refunds

The Solution: Database Transactions + Row Locking

I fixed the issue by enforcing atomic operations using database transactions.

Correct Approach

  • Wrap read + write inside a transaction
  • Lock the user row
  • Perform validation inside the transaction
  • Commit only if everything succeeds

Example (Laravel)

DB::transaction(function () use ($userId, $amount) {
    $wallet = Wallet::where('user_id', $userId)
        ->lockForUpdate()
        ->first();

    if ($wallet->balance < $amount) {
        throw new Exception('Insufficient balance');
    }

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

    Withdrawal::create([
        'user_id' => $userId,
        'amount' => $amount,
    ]);
});
Why This Works
lockForUpdate() prevents concurrent reads

Only one request can modify the balance at a time

Other requests wait until the transaction finishes

Guarantees financial consistency

Key Takeaways
Never trust balance checks outside transactions

Always assume concurrent requests

Financial logic must be ACID-compliant

Bugs like this don’t show up in local testing

Final Thoughts
This incident reinforced a core lesson:

Concurrency bugs don’t break systems slowly — they break trust instantly.

If you work with money, bets, points, or credits — transactions are not optional.
Tags:LaravelDatabaseTransactionsConcurrencySecurity
Share:

Portfolio

Portfolio of Fahim Mohammad Chowdhury, a full-stack software engineer specializing in Laravel backend development and Vue.js / Nuxt.js frontend applications. I build scalable, secure, and high-performance web applications.

Connect

© 2026 Portfolio. All rights reserved.