This tutorial explains how to test race conditions and shows PHP code examples before and after handling race conditions using proper techniques.
What Is a Race Condition
A race condition happens when the outcome of an operation depends on the timing of multiple concurrent executions. Without proper synchronization, data can become inconsistent or corrupted.
Common Real-World Examples
- Double spending on wallet balance
- Overselling limited stock
- Duplicate order creation
- Multiple password reset tokens generated
Race Condition Flow Overview
flowchart TD
A[Multiple Requests] --> B[Read Same Data]
B --> C[Process Logic]
C --> D[Write Data]
D --> E[Inconsistent Result]
Example Scenario: Wallet Balance Deduction
Assume a user has a balance of 100. Two requests attempt to deduct 80 simultaneously.
Code Example Before Handling Race Condition
The following code looks correct at first glance, but it is vulnerable to race conditions because there is no locking or transaction protection.
$user = getUserById($userId);
if ($user['balance'] >= 80) {
$newBalance = $user['balance'] - 80;
updateBalance($userId, $newBalance);
}
If two requests read the balance at the same time, both will pass the condition and deduct the balance, resulting in a negative or incorrect value.
How to Test Race Conditions
Using Multiple Parallel Requests
You can simulate race conditions by sending concurrent HTTP requests to the same endpoint.
for i in {1..5}; do
curl -X POST http://localhost/deduct-balance &
done
wait
This command sends multiple requests at nearly the same time, increasing the chance of triggering a race condition.
Indicators That a Race Condition Exists
- Negative balances
- Duplicate transactions
- Stock values below zero
- Inconsistent database records
Handling Race Condition with Database Transactions
Using Transaction and Row Locking
One of the most reliable ways to prevent race conditions is to use database transactions combined with row-level locking.
$pdo->beginTransaction();
$stmt = $pdo->prepare(
"SELECT balance FROM users WHERE id = ? FOR UPDATE"
);
$stmt->execute([$userId]);
$balance = $stmt->fetchColumn();
if ($balance >= 80) {
$stmt = $pdo->prepare(
"UPDATE users SET balance = balance - 80 WHERE id = ?"
);
$stmt->execute([$userId]);
}
$pdo->commit();
The FOR UPDATE clause ensures that other transactions must wait until
the current one finishes before accessing the same row.
Alternative Approach: Atomic Update
Another effective method is to let the database handle validation and update in a single atomic query.
$stmt = $pdo->prepare(
"UPDATE users
SET balance = balance - 80
WHERE id = ? AND balance >= 80"
);
$stmt->execute([$userId]);
if ($stmt->rowCount() === 0) {
throw new Exception("Insufficient balance");
}
This approach avoids race conditions by ensuring the condition and update are executed as one operation.
Race Condition Flow After Handling
flowchart TD
A[Concurrent Requests] --> B[Row Lock or Atomic Query]
B --> C[Single Valid Update]
C --> D[Consistent Data]
Best Practices to Prevent Race Conditions
- Always use database transactions for critical updates
- Prefer atomic SQL queries over read-modify-write logic
- Never rely solely on application-level checks
- Test concurrency scenarios during development
Conclusion
Race conditions are subtle but dangerous bugs that can cause serious data integrity issues. By testing with concurrent requests and applying proper handling techniques such as transactions, row locking, or atomic updates, PHP applications can safely handle high-concurrency scenarios.
Understanding and addressing race conditions early will significantly improve the reliability and security of your application.