A user clicks “Buy Now” three times because the page loads slowly. Three orders created. Three credit card charges. One refund nightmare that costs more in engineering time than the original sale was worth.

Concurrency bugs are silent until they’re catastrophic. They don’t show up in unit tests. They don’t appear in staging. They emerge at scale, during peak traffic, when you’re least prepared.

The Nature of Race Conditions

A race condition occurs when two processes read the same data, perform calculations, then write results — without knowing the other exists.

// The bug that costs thousands
public function redeemVoucher($id)
{
    $voucher = Voucher::find($id);
    
    // User A reads: used = 49
    // User B reads: used = 49 (simultaneously)
    
    if ($voucher->used >= $voucher->quota) {
        return response()->json(['error' => 'Voucher depleted'], 400);
    }
    
    // Both pass validation
    // Both write: used = 50
    // One redemption is lost
    
    $voucher->used += 1;
    $voucher->save();
    
    return response()->json(['message' => 'Success']);
}

The voucher had a quota of 50. Both users read 49, both passed validation, both wrote 50. One free redemption slipped through.

Pessimistic Locking: Trust No One

Pessimistic locking assumes conflict will happen. It locks the row before reading, forcing other processes to wait.

use Illuminate\Support\Facades\DB;

public function redeemVoucher($id)
{
    return DB::transaction(function () use ($id) {
        // Lock this row until transaction completes
        $voucher = Voucher::where('id', $id)
            ->lockForUpdate()
            ->first();
        
        if ($voucher->used >= $voucher->quota) {
            throw new \Exception('Voucher depleted');
        }
        
        $voucher->used += 1;
        $voucher->save();
        
        return response()->json(['message' => 'Voucher redeemed']);
    });
}

lockForUpdate() tells the database: “This row is mine until I commit.” Other transactions attempting to read this row will block, waiting their turn.

The sequence becomes:

User A: Lock row → Read (49) → Validate → Write (50) → Release
User B: Wait... → Lock row → Read (50) → Validate → REJECT

Correctness restored.

Optimistic Locking: Detect, Don’t Prevent

Optimistic locking assumes conflicts are rare. Instead of blocking, it detects conflicts at write time.

// Migration: add version column
Schema::table('products', function (Blueprint $table) {
    $table->integer('version')->default(0);
});
public function updateStock(Request $request, $id)
{
    $product = Product::find($id);
    $currentVersion = $product->version;
    
    // Time passes, calculations happen...
    
    $affected = Product::where('id', $id)
        ->where('version', $currentVersion)
        ->update([
            'stock' => $product->stock - $request->quantity,
            'version' => $currentVersion + 1,
        ]);
    
    if ($affected === 0) {
        return response()->json([
            'error' => 'Data changed, please refresh'
        ], 409);
    }
    
    return response()->json(['message' => 'Stock updated']);
}

If another process modified the row (incrementing the version), our update affects zero rows. We detect the conflict and ask the user to retry.

When to use optimistic vs pessimistic:

Scenario Strategy
High contention (flash sales) Pessimistic
Rare conflicts (user profiles) Optimistic
External API calls in transaction Optimistic
Financial operations Pessimistic

Cache Locks: Beyond the Database

Some operations span multiple systems. Database locks don’t help when you’re calling payment APIs, sending emails, or coordinating microservices.

use Illuminate\Support\Facades\Cache;

public function processOrder($orderId)
{
    $lock = Cache::lock('process-order-' . $orderId, 10);
    
    if (!$lock->get()) {
        return response()->json([
            'error' => 'Order is being processed'
        ], 429);
    }
    
    try {
        $order = Order::find($orderId);
        
        if ($order->status !== 'pending') {
            return response()->json(['message' => 'Already processed']);
        }
        
        // External API call — can't use DB transaction
        $payment = $this->chargePaymentProvider($order);
        
        $order->status = 'paid';
        $order->transaction_id = $payment->id;
        $order->save();
        
        dispatch(new SendOrderConfirmation($order));
        
        return response()->json(['message' => 'Order processed']);
    } finally {
        $lock->release();
    }
}

The finally block ensures the lock releases even if an exception occurs. Without it, the lock would persist until timeout — blocking all subsequent requests.

Idempotency: Same Request, Same Result

Idempotency means executing the same request multiple times produces the same effect as executing it once.

// Middleware: IdempotencyMiddleware.php
class IdempotencyMiddleware
{
    public function handle($request, Closure $next)
    {
        $key = $request->header('Idempotency-Key');
        
        if (!$key) {
            return response()->json([
                'error' => 'Idempotency-Key header required'
            ], 400);
        }
        
        $cacheKey = "idempotency:" . auth()->id() . ":{$key}";
        
        // Return cached response if exists
        if ($cached = Cache::get($cacheKey)) {
            return response()->json($cached['data'], $cached['status'])
                ->header('Idempotent-Replayed', 'true');
        }
        
        // Prevent race condition on the idempotency check itself
        $lock = Cache::lock($cacheKey . ':lock', 10);
        
        if (!$lock->get()) {
            return response()->json([
                'error' => 'Request in progress'
            ], 409);
        }
        
        try {
            $response = $next($request);
            
            // Cache for 24 hours
            Cache::put($cacheKey, [
                'data' => $response->getData(),
                'status' => $response->status(),
            ], now()->addHours(24));
            
            return $response;
        } finally {
            $lock->release();
        }
    }
}

Client generates a unique key per logical operation:

async function submitOrder(orderData) {
    const idempotencyKey = crypto.randomUUID();
    
    const response = await fetch('/api/orders', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Idempotency-Key': idempotencyKey,
        },
        body: JSON.stringify(orderData)
    });
    
    return response.json();
}

User clicks submit five times? Same idempotency key, same cached response. One order created.

Atomic Operations: Let the Database Handle It

For simple increments and decrements, don’t read-modify-write. Let the database do it atomically:

// ❌ Race condition waiting to happen
$user = User::find(1);
$user->balance -= 100;
$user->save();

// ✅ Atomic operation
User::where('id', 1)->decrement('balance', 100);

// ✅ Atomic with condition
User::where('id', 1)
    ->where('balance', '>=', 100)
    ->decrement('balance', 100);

// ✅ Multiple columns atomically
Product::where('id', $productId)->update([
    'stock' => DB::raw('stock - 1'),
    'sold' => DB::raw('sold + 1'),
]);

The database executes these as single operations. No gap between read and write where another process could interfere.

The Complete Pattern: Flash Sale

Real systems combine multiple strategies:

class FlashSaleController extends Controller
{
    public function purchase(Request $request, $productId)
    {
        $validated = $request->validate([
            'quantity' => 'required|integer|min:1|max:5',
        ]);
        
        // Layer 1: Cache lock prevents same user double-submit
        $userLock = Cache::lock(
            "purchase:{$productId}:" . auth()->id(),
            10
        );
        
        if (!$userLock->get()) {
            return response()->json([
                'error' => 'Processing your previous request'
            ], 429);
        }
        
        try {
            return DB::transaction(function () use ($productId, $validated) {
                // Layer 2: Database lock prevents overselling
                $product = Product::where('id', $productId)
                    ->lockForUpdate()
                    ->first();
                
                if ($product->stock < $validated['quantity']) {
                    return response()->json([
                        'error' => 'Insufficient stock'
                    ], 400);
                }
                
                $order = Order::create([
                    'user_id' => auth()->id(),
                    'product_id' => $productId,
                    'quantity' => $validated['quantity'],
                    'total' => $product->price * $validated['quantity'],
                ]);
                
                // Layer 3: Atomic update with safety check
                $updated = Product::where('id', $productId)
                    ->where('stock', '>=', $validated['quantity'])
                    ->update([
                        'stock' => DB::raw("stock - {$validated['quantity']}"),
                        'sold' => DB::raw("sold + {$validated['quantity']}"),
                    ]);
                
                if (!$updated) {
                    throw new \Exception('Stock changed during transaction');
                }
                
                return response()->json([
                    'message' => 'Purchase successful',
                    'order_id' => $order->id,
                ], 201);
            });
        } finally {
            $userLock->release();
        }
    }
}

Three layers of protection:

  1. Cache lock — Prevents same user from spamming requests
  2. Database lock — Serializes access to the product row
  3. Atomic update with condition — Final safety net

Common Mistakes

Forgetting to release locks:

// ❌ Lock orphaned if exception thrown
$lock = Cache::lock('key', 10);
$lock->get();
$this->riskyOperation(); // throws exception
$lock->release(); // never reached

// ✅ Always use try-finally
$lock = Cache::lock('key', 10);
if ($lock->get()) {
    try {
        $this->riskyOperation();
    } finally {
        $lock->release();
    }
}

Locking too broadly:

// ❌ Blocks all voucher operations
$lock = Cache::lock('vouchers', 10);

// ✅ Lock specific resource
$lock = Cache::lock("voucher:{$voucherId}", 10);

Heavy processing inside locks:

// ❌ Lock held for 5 seconds
$lock = Cache::lock('key', 30);
if ($lock->get()) {
    try {
        $data = $this->heavyCalculation(); // 5 seconds
        $this->save($data);
    } finally {
        $lock->release();
    }
}

// ✅ Minimize lock duration
$data = $this->heavyCalculation(); // Outside lock

$lock = Cache::lock('key', 5);
if ($lock->get()) {
    try {
        $this->save($data); // Only critical section locked
    } finally {
        $lock->release();
    }
}

Choosing Your Strategy

Problem Solution
Database row conflicts lockForUpdate() in transaction
Cross-service operations Cache locks (Redis)
User double-clicks Idempotency keys
Simple counters Atomic increment()/decrement()
Rare conflicts, high reads Optimistic locking (version column)
API rate abuse Rate limiting middleware

The Cost of Ignoring Concurrency

Two hours implementing proper locking saves:

  • Weeks of debugging intermittent production bugs
  • Thousands in refunds and chargebacks
  • Customer trust that takes years to rebuild

Concurrency bugs don’t announce themselves. They wait for your highest traffic moment, then strike. The Black Friday flash sale. The viral product launch. The moment you can least afford to fail.

Build the defenses before you need them.


The code that handles one user elegantly often fails spectacularly with a thousand. Design for concurrency from the start.