Race Conditions Explained: The Concurrency Bug Every Backend Developer Should Understand
Imagine you’re trying to buy the last ticket for your favorite concert.
The website shows:
Only 1 ticket remaining.
At exactly the same moment, someone else clicks Buy.
Both of you complete payment.
Both of you receive confirmation emails.
But there was only one ticket.
How did two people successfully purchase the same seat?
Now imagine the same thing happening in a banking application.
Two ATM withdrawals happen at almost the same time.
Both check the balance before either transaction finishes.
Both think there’s enough money.
Both approve the withdrawal.
The account ends up with a negative balance.
Neither application is necessarily “broken.”
Instead, they suffer from one of the most common problems in software engineering:
Race conditions.
Race conditions are among the hardest bugs to reproduce because they don’t happen every time. They may only appear under heavy traffic, high concurrency, or perfect timing. Everything works during testing until your application reaches production.
In this article, we’ll explore what race conditions are, why they happen, how they relate to idempotency, and the techniques developers use to prevent them.
What Is a Race Condition?
A race condition occurs when two or more operations access and modify the same piece of data at the same time, and the final result depends on the order in which they execute.
The important phrase is:
The outcome depends on timing.
That’s what makes race conditions so dangerous.
Sometimes everything works.
Sometimes everything breaks.
The exact same code can produce different results simply because two requests happened a few milliseconds apart.
A Simple Analogy
Imagine two people standing in front of a cookie jar.
The jar contains exactly one cookie.
Both people look inside.
Both see one cookie.
Both reach in.
Both believe they’ll get the cookie.
Reality says otherwise.
Only one cookie exists.
The mistake happened because both people checked the state before either updated it.
Software behaves exactly the same way.
A Real Banking Example
Suppose an account has:
1
Balance = $100
Two withdrawal requests arrive simultaneously.
1
2
3
Request A → Withdraw $80
Request B → Withdraw $50
Without synchronization:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Request A
↓
Read Balance ($100)
------------
Request B
↓
Read Balance ($100)
------------
Request A
Balance = $20
------------
Request B
Balance = $50
Depending on timing, the final balance might be:
1
2
3
4
5
6
7
8
9
$20
or
$50
or
-$30
None of these outcomes are guaranteed.
This is a race condition.
Why It Works During Development
Most developers test applications alone.
One request.
One browser.
One user.
Everything works perfectly.
Production is different.
Imagine:
- 5,000 users
- Hundreds of requests per second
- Multiple application servers
- Database replication
- Network latency
The probability of two requests colliding becomes much higher.
Race conditions often appear only after an application becomes successful.
Ironically, scaling your application can reveal bugs that never existed during development.
Race Conditions vs Idempotency
If you’ve read my previous article on idempotency, if not, read it first here. You might wonder whether they’re the same thing.
They’re related, but they solve different problems.
Idempotency answers:
What if the same request is sent twice?
Example:
1
2
3
4
5
6
7
8
9
POST /payments
↓
Retry
↓
POST /payments
The solution is an Idempotency-Key.
The same request produces the same result.
Race conditions answer:
What if different requests happen at the same time?
Example:
1
2
3
4
5
User A buys last ticket
↓
User B buys last ticket
These are two legitimate requests from different users.
An idempotency key won’t help because the requests are not duplicates.
Race conditions require synchronization, not deduplication.
Real-World Examples
Airline Seat Booking
Only one seat remains.
Two customers purchase it simultaneously.
Without proper locking:
- Seat sold twice
- Refund required
- Customer frustration
E-Commerce Inventory
Stock:
1
2
3
Laptop
Quantity = 1
Two customers purchase simultaneously.
Without protection:
Inventory becomes:
1
-1
Now you’ve sold a product you don’t have.
Loan Approval Systems
Imagine two loan officers reviewing the same application.
Officer A:
Approve.
Officer B:
Reject.
If both updates happen simultaneously without coordination, the final loan status depends entirely on timing.
Coupon Redemption
Promotion:
1
First 100 customers only
If multiple requests update the redemption count simultaneously, you might accidentally issue:
1
2
3
4
5
6
7
103
105
110
coupons.
How Race Conditions Happen
Most race conditions follow this pattern:
1
2
3
4
5
6
7
8
9
Read
↓
Modify
↓
Write
The danger lies between Read and Write.
Example:
1
2
3
4
5
6
7
8
9
Read Balance
↓
Calculate New Balance
↓
Update Balance
If another request changes the balance between those steps, your calculation becomes outdated.
This is known as a Lost Update problem.
Solution 1: Database Transactions
Transactions ensure multiple operations succeed or fail together.
Example:
1
2
3
4
5
6
7
8
9
10
11
BEGIN;
SELECT balance
FROM accounts
WHERE id = 1;
UPDATE accounts
SET balance = balance - 80
WHERE id = 1;
COMMIT;
Transactions protect data consistency.
However, they don’t automatically eliminate every race condition.
Isolation levels matter too.
Solution 2: Row-Level Locks
Many relational databases allow locking a row while it’s being updated.
Example:
1
2
3
4
SELECT *
FROM accounts
WHERE id = 1
FOR UPDATE;
Now:
Request A locks the row.
Request B must wait.
Only after Request A finishes can Request B continue.
This guarantees consistent updates.
Solution 3: Optimistic Locking
Instead of preventing conflicts, optimistic locking detects them.
Imagine a version number.
1
2
3
4
5
Account
Balance = 100
Version = 5
Update:
1
WHERE Version = 5
If another request updates the row first:
1
Version = 6
Your update fails.
The client retries with fresh data.
Optimistic locking works well when collisions are relatively rare.
Solution 4: Distributed Locks
What if your application runs on multiple servers?
1
2
3
4
5
6
7
8
9
Server A
↓
Database
↑
Server B
A normal in-memory lock won’t work because each server has its own memory.
Instead, developers use distributed locking systems like:
- Redis (Redlock)
- ZooKeeper
- etcd
- Consul
These coordinate access across multiple application instances.
Solution 5: Atomic Database Operations
Sometimes you don’t need to:
1
2
3
4
5
6
7
8
9
Read
↓
Calculate
↓
Write
Instead, let the database perform the update atomically.
Bad:
1
2
3
4
5
SELECT quantity;
quantity--;
UPDATE products;
Better:
1
2
3
4
5
UPDATE products
SET quantity = quantity - 1
WHERE quantity > 0;
Now the database guarantees consistency.
Detecting Race Conditions
One reason race conditions are difficult is that they rarely appear during manual testing.
Ways to uncover them include:
- Load testing with concurrent users
- Stress testing
- Running parallel integration tests
- Simulating delayed responses
- Chaos engineering
- Monitoring production logs
If a bug only appears “sometimes,” concurrency should be one of your first suspects.
Best Practices
When designing systems that handle shared data:
- Keep transactions short.
- Avoid long-running locks.
- Prefer atomic database operations where possible.
- Use optimistic locking when contention is low.
- Use pessimistic locking for critical resources.
- Test with concurrent requests, not just sequential ones.
- Understand your database’s transaction isolation levels.
Most importantly, always ask:
“What happens if two users do this at exactly the same time?”
Final Thoughts
Race conditions aren’t caused by bad developers, they’re caused by systems becoming concurrent.
As applications grow, users interact simultaneously, background jobs overlap, and services communicate in parallel. Timing becomes unpredictable.
That’s why building reliable software isn’t just about writing correct logic for one request. It’s about ensuring your logic remains correct when hundreds or thousands of requests happen together.
If idempotency protects you from duplicate requests, race condition handling protects you from competing requests.
Together, they form two of the most important building blocks for designing resilient APIs and distributed systems.
The next time you write code that reads, modifies, and writes shared data, pause for a moment and ask:
“What happens if someone else does this at the exact same time?”
If you don’t know the answer, you’ve just found your next engineering problem to solve.
