Race condition — two concurrent operations producing wrong results due to timing
Intermediate5 min read·eng-20-011
interviewperformancesecurity
Concept
Race condition — a bug that occurs when the outcome of a program depends on the timing or interleaving of multiple concurrent operations. Two (or more) processes or threads access shared state simultaneously, and the result is non-deterministic.
Why race conditions are hard: They're intermittent. The bug only manifests when two operations happen to execute at exactly the wrong time. In testing, it works fine. In production under load, it fails.
Classic example — bank account:
text
Process A: reads balance = $100
Process B: reads balance = $100
Process A: subtracts $50, writes $50
Process B: subtracts $50, writes $50 ← overwrites A's write!
Final balance: $50 (should be $0)Both processes read $100 before either wrote, causing $50 to be "double-spent."
Common race conditions in PHP/Laravel:
- Two requests check "user has credits" simultaneously, both see 1 credit, both deduct 1 credit → negative balance.
- Two workers pick up the same job.
- Two processes create the same unique record → duplicate key violation (but only sometimes, depending on timing).
Solutions:
- Optimistic locking: Add a
versioncolumn. On update, check version still matches. If someone else updated first, retry. - Pessimistic locking: Lock the row when reading.
lockForUpdate()orsharedLock()in Laravel. - Atomic operations: Use database-level atomic operations (
INCREMENT,DECREMENT) instead of read-modify-write. - Redis locks / mutexes:
Cache::lock()for distributed locks across multiple servers. - Unique constraints: Let the database enforce uniqueness — duplicate inserts fail with an exception.
Code Example
php
<?php
// RACE CONDITION — the "check then act" anti-pattern
public function purchaseWithCredits(User $user, int $amount): void
{
// RACE CONDITION: Two requests can both pass the check
if ($user->credits < $amount) {
throw new \Exception('Insufficient credits');
}
// Both processes reach here if they both read credits before either writes
$user->decrement('credits', $amount); // second process overwrites first!
}
// FIX 1: Pessimistic locking — lock the row
public function purchaseWithCredits(User $user, int $amount): void
{
DB::transaction(function () use ($user, $amount) {
// lockForUpdate() prevents other reads (SELECT ... FOR UPDATE)
$user = User::where('id', $user->id)->lockForUpdate()->first();
if ($user->credits < $amount) {
throw new \Exception('Insufficient credits');
}
$user->decrement('credits', $amount);
// Transaction ends → lock released → other processes can proceed
});
}
// FIX 2: Optimistic locking
public function purchaseWithCredits(User $user, int $amount): void
{
$maxAttempts = 3;
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
$updated = User::where('id', $user->id)
->where('credits', '>=', $amount) // check credits
->where('version', $user->version) // check version (optimistic lock)
->update([
'credits' => DB::raw("credits - {$amount}"),
'version' => DB::raw('version + 1'),
]);
if ($updated === 1) return; // success!
// 0 rows updated: either insufficient credits OR version changed (conflict)
$user = $user->fresh(); // reload and retry
}
throw new \Exception('Update failed after retries');
}
// FIX 3: Atomic DB operation (no read-modify-write)
// MySQL: only update if balance won't go negative
$affected = DB::table('users')
->where('id', $user->id)
->where('credits', '>=', $amount) // atomic check+update
->decrement('credits', $amount);
if ($affected === 0) {
throw new \Exception('Insufficient credits');
}
// Redis distributed lock — for cross-server races
$lock = Cache::lock("purchase:{$user->id}", 5); // 5-second lock
try {
$lock->block(3); // wait up to 3s for the lock
// Only one process runs this at a time per user
if ($user->fresh()->credits < $amount) throw new \Exception('Insufficient credits');
$user->decrement('credits', $amount);
} finally {
$lock->release();
}