A race condition is one of those bugs that can remain invisible during development and suddenly become a serious problem in production.
Your code may work perfectly when one user performs an action.
Then hundreds of users do the same thing at nearly the same time.
And suddenly, your application produces a result that should have been impossible.
A classic example is inventory management.
Imagine a product has only one item left in stock.
Two customers try to purchase it at almost exactly the same time.
If the application isn't designed to handle concurrent requests correctly, both customers could successfully purchase the last item.
Let's see why.
The Race Condition
Suppose our products table contains:
Product: Laravel T-Shirt
Stock: 1
Our checkout code might initially look perfectly reasonable:
$product = Product::findOrFail($productId);
if ($product->stock > 0) {
$product->stock--;
$product->save();
// Create order...
}
At first glance, there doesn't seem to be anything wrong with this code.
But consider what happens when two requests arrive at almost the same time.
Request A Request B
Read stock = 1 Read stock = 1
stock > 0 ✓ stock > 0 ✓
stock = 0 stock = 0
Save Save
Create order ✓ Create order ✓
The application has now sold two items even though only one was available.
This is a race condition.
Why This Happens
The problem isn't the if statement itself.
The problem is that reading the value and updating the value are separate operations.
Between those operations, another request can read the same data.
This is known as a time-of-check to time-of-use problem.
In simplified terms:
Check → Wait → Use
Another request can modify the data during that gap.
And the more concurrent your application becomes, the more important this becomes.
Solution 1: Atomic Updates
For simple inventory operations, you can often avoid the race condition entirely by letting the database perform the operation atomically.
Instead of:
$product = Product::findOrFail($productId);
if ($product->stock > 0) {
$product->stock--;
$product->save();
}
Use a conditional update:
$updated = Product::whereKey($productId)
->where('stock', '>', 0)
->decrement('stock');
Now the database performs the check and update as one operation.
You can check whether anything was actually updated:
if ($updated === 0) {
throw new OutOfStockException();
}
The important part is:
stock > 0
+
decrement stock
=
one database operation
There is no opportunity for another request to sneak between the check and the update.
Solution 2: Pessimistic Locking
Atomic updates are excellent when the operation is simple.
But sometimes the business logic is more complicated.
For example:
Check inventory
↓
Apply discount
↓
Reserve inventory
↓
Create order
↓
Create payment
In situations where you need to read a record, perform several operations, and then update it, a database lock can be more appropriate.
Laravel provides lockForUpdate() for this purpose.
DB::transaction(function () use ($productId) {
$product = Product::whereKey($productId)
->lockForUpdate()
->firstOrFail();
if ($product->stock <= 0) {
throw new OutOfStockException();
}
$product->decrement('stock');
// Create order...
});
The important part is:
->lockForUpdate()
The database locks the selected row until the transaction completes.
Another transaction attempting to acquire the same lock has to wait.
The flow becomes:
Transaction A
↓
Lock product
↓
Check stock
↓
Decrease stock
↓
Commit
↓
Release lock
Transaction B
↓
Wait for lock
↓
Read updated stock
↓
See stock = 0
↓
Reject purchase
Now only one customer can successfully purchase the final item.
When Should You Use Which?
There isn't one solution that should be used everywhere.
Use an atomic update when:
- The operation is simple.
- You only need to increment or decrement a value.
- The database can express the condition directly.
- You don't need to perform several dependent operations between the read and write.
For example:
Product::whereKey($productId)
->where('stock', '>', 0)
->decrement('stock');
Use lockForUpdate() when:
- You need to read a record first.
- Multiple operations depend on that value.
- Several related database changes must happen together.
- You need to protect a critical section inside a transaction.
For example:
DB::transaction(function () use ($productId) {
$product = Product::whereKey($productId)
->lockForUpdate()
->firstOrFail();
// Multiple dependent operations...
});
What About Cache::lock()?
Database locks aren't the only option.
Laravel also provides distributed atomic locks through Cache::lock().
For example:
Cache::lock(
"product:{$productId}",
10
)->block(5, function () use ($productId) {
// Critical section...
});
This can be useful when the operation isn't limited to a single database transaction or when you need coordination across multiple application servers.
However, a cache lock and a database transaction solve different problems.
A cache lock can coordinate application-level access, while a database transaction and row lock protect database consistency.
Choose the mechanism based on what you're actually trying to protect.
A Common Mistake
One of the most common mistakes is assuming that this is safe:
if ($product->stock > 0) {
$product->decrement('stock');
}
It isn't necessarily safe.
The value was read before the update.
Another request could change the stock between those operations.
The safer version is to make the condition part of the database operation:
Product::whereKey($productId)
->where('stock', '>', 0)
->decrement('stock');
The database can then guarantee that the condition and update happen atomically.
Race Conditions Aren't Just About Inventory
Inventory is an easy example to understand, but the same problem appears in many other systems.
Wallet balances
Balance = ₹1,000
Request A → Withdraw ₹800
Request B → Withdraw ₹800
Both see ₹1,000
Without proper concurrency control, the account could end up with an invalid balance.
Coupon usage
Remaining uses = 1
Request A → Apply coupon
Request B → Apply coupon
Both requests may consume the final usage.
Seat reservations
Available seats = 1
User A → Reserve seat
User B → Reserve seat
Without concurrency control, the same seat could be assigned twice.
Subscription limits
Plan limit = 10 projects
Two requests create projects simultaneously.
Both may observe the same count before either request updates it.
The Bigger Lesson
Race conditions aren't caused by Laravel.
They're caused by concurrent access to shared state.
Laravel gives you several tools to handle them:
- Atomic database updates
- Database transactions
lockForUpdate()Cache::lock()- Unique database constraints
- Optimistic locking patterns
The important part isn't knowing every tool.
It's recognizing when your code contains this pattern:
Read shared state
↓
Make a decision
↓
Modify shared state
Whenever multiple requests can execute that sequence concurrently, you should ask yourself:
What happens if two requests execute this at exactly the same time?
That question alone can prevent some very expensive production bugs.
Final Thoughts
A race condition can be difficult to reproduce locally because everything appears to work when requests arrive one at a time.
Production is different.
Multiple users, multiple workers, multiple servers, retries, queues, and concurrent requests can expose problems that were invisible during development.
That's why production-ready Laravel applications need to consider not only what happens when the code runs, but also what happens when the code runs concurrently.
When shared state is involved, don't just ask:
"Does this code work?"
Ask:
"What happens if two requests execute this code simultaneously?"
That's where production engineering begins.