System Design
Race Conditions: The Bug That Corrupts Your Data Without Crashing
Threads make your code fast - and dangerous. A race condition can silently corrupt your data with no error, no crash, nothing in the logs. Here's how two threads can drain a bank account twice, and the tools that stop them.
Threads are one of the most powerful tools in programming. They let your program do many things at once, use your hardware fully, and stay fast and responsive. But that power comes with a dark side: threads that aren't managed carefully introduce some of the most subtle, dangerous, and hard-to-find bugs in all of software. The scariest of them is the race condition - a bug that can silently corrupt your data while your program runs perfectly, throwing no error and leaving no trace. This post explains what a race condition is, watches one drain a bank account twice, and covers the tools that prevent it.
(This builds on the previous post about threads, cores, and context switching - if you're fuzzy on how multiple threads run, start there.)
The power and peril of threads
Threads enable concurrent execution - multiple sequences of instructions making progress at once - which is what gives you better resource utilization and speed. A program that uses threads well can handle many tasks simultaneously, keep the CPU busy, and respond quickly. This is genuinely one of programming's most valuable capabilities.
But threads share something dangerous: memory. Multiple threads in a program can read and write the same data. And when two threads touch the same data at the same time without coordination, things go wrong in ways that are uniquely nasty. Unmanaged threads lead to a family of concurrency bugs - chief among them race conditions (data corruption from concurrent access) and deadlocks (threads permanently blocking each other) - plus the whole discipline of locking strategies built to prevent them. The power of threads and the peril of threads are the same thing: shared access to data. Used with care, it's fast and safe. Used carelessly, it silently corrupts.
What is a race condition?
A race condition occurs when two or more threads try to read and modify the same shared data at the same time, and the final result depends on the order in which the threads happen to execute - an order you don't control.
The name captures it: the threads are "racing" to access the data, and who wins the race determines the outcome. Because thread scheduling is unpredictable (the OS decides when each thread runs, and it varies run to run), the result is non-deterministic - it can be different each time, and often it's simply wrong. The same code, with the same inputs, can produce a correct result one moment and corrupted data the next, purely based on the timing of which thread got to the data first.
The heart of the problem is a sequence that looks innocent: read a value, modify it, write it back. This "read-modify-write" isn't a single instant action - it's several steps - and if another thread sneaks in between the read and the write, both threads end up working from the same stale starting value, and one thread's update silently overwrites the other's. Let's watch exactly that happen with real money.
The double deduction problem
Here's a concrete, real-world race condition - the kind that keeps banking engineers up at night.
You have a bank account with a balance of ₹1,000. At the exact same moment, two things happen:
- An auto-payment of ₹600 triggers via UPI.
- You withdraw ₹600 at an ATM.
Two separate threads (or systems) now process these two transactions concurrently. Each does the natural thing: read the balance, check there's enough, deduct ₹600, write the new balance. Here's how it should work: the account has ₹1,000, so one of these ₹600 deductions should succeed (leaving ₹400), and the other should fail for insufficient funds (₹400 isn't enough for another ₹600). Final balance: ₹400, one transaction succeeded, one correctly rejected.
But watch what actually happens when they race:
- Thread A (UPI) reads the balance: ₹1,000. Plenty for ₹600.
- Thread B (ATM) reads the balance: ₹1,000. Also plenty for ₹600. (Both read before either has written.)
- Thread A deducts: 1,000 − 600 = ₹400. Writes ₹400.
- Thread B deducts: 1,000 − 600 = ₹400 (it's still working from the ₹1,000 it read!). Writes ₹400.
Final stored balance: ₹400. But ₹1,200 left the account - two ₹600 payments both succeeded, against an account that only had ₹1,000. The balance says ₹400 and looks perfectly reasonable, but the account was overdrawn: money that shouldn't exist was paid out. Both transactions succeeded when only one should have. The second thread read a stale balance (the ₹1,000 from before the first deduction), so its check passed when it shouldn't have, and its write clobbered reality.
sequenceDiagram
participant A as Thread A (UPI ₹600)
participant Bal as Balance
participant B as Thread B (ATM ₹600)
A->>Bal: Read balance → ₹1,000
B->>Bal: Read balance → ₹1,000
Note over A,B: Both read ₹1,000 before either writes
A->>A: 1,000 − 600 = ₹400 (enough? yes)
B->>B: 1,000 − 600 = ₹400 (enough? yes - but stale!)
A->>Bal: Write ₹400
B->>Bal: Write ₹400
Note over Bal: Balance = ₹400, but ₹1,200 was paid out
This sequence diagram shows the double-deduction race condition. Thread A, a UPI auto-payment, reads the balance as one thousand rupees. Thread B, an ATM withdrawal, also reads one thousand rupees - both read before either writes. Thread A computes one thousand minus six hundred equals four hundred and sees the funds are sufficient. Thread B does the same, but it's working from the stale one-thousand it read earlier. Both write four hundred. The final stored balance is four hundred, but twelve hundred rupees was actually paid out from an account that only had one thousand. Both transactions succeeded when only one should have.
Why race conditions are so dangerous
Here's the truly frightening part, and what makes race conditions uniquely awful among bugs: nothing goes wrong visibly. No error is thrown. No exception. No crash. No red text in the logs. The program runs perfectly, every function returns successfully, everything looks fine. It's just that the data is silently, quietly wrong.
This makes race conditions extraordinarily hard to deal with:
- They're silent. Unlike a crash that screams for attention, a race condition just leaves corrupted data behind and carries on. You often don't find out until much later, when the wrong data causes a downstream problem or someone notices the numbers don't add up.
- They're intermittent. A race condition only happens when the timing lines up just so - when two threads hit the data in exactly the wrong overlapping way. Most of the time, the threads happen not to collide, and everything works. So the bug appears rarely and unpredictably.
- They're nearly impossible to reproduce. Because they depend on precise timing you don't control, you often can't make them happen on demand. A race condition that corrupted production data might refuse to reproduce in testing no matter how hard you try - the timing just doesn't line up when you're watching.
Warning: The most dangerous bugs aren't the ones that crash - those announce themselves and get fixed. The dangerous ones are the ones that don't crash and instead corrupt data silently. A race condition is the classic example: your program runs flawlessly while quietly producing wrong results. This is why concurrency bugs have a fearsome reputation - they hide, they're intermittent, and by the time you notice the corrupted data, the timing that caused it is long gone.
Solutions: locks, synchronization, and atomic operations
The root cause is clear: multiple threads accessing and modifying shared data at the same time, with one thread's read-modify-write interleaving with another's. So the solutions all share one goal - ensure that only one thread touches the shared data at a time, so the read-modify-write completes as an uninterruptible unit. The main tools:
Locks. A lock lets a thread acquire exclusive access to a resource before modifying it. A thread "locks" the shared data, does its read-modify-write, then "unlocks" it. While it holds the lock, no other thread can touch that data - they must wait their turn. In the bank example, Thread A would lock the balance, read ₹1,000, deduct, write ₹400, and unlock - and only then could Thread B proceed, now correctly reading ₹400, seeing it's insufficient for ₹600, and failing as it should. The lock forces the two transactions to happen one after another instead of overlapping.
Synchronization. More broadly, synchronization ensures threads coordinate their execution around shared resources - mechanisms that impose order so threads don't step on each other. Locks are one form; synchronization is the general discipline of making threads cooperate on shared data rather than colliding.
Atomic operations. An atomic operation is one that is indivisible - it completes entirely, without interruption, as a single unbreakable step. Because it can't be interrupted partway, no other thread can sneak in during it. For simple operations (like incrementing a counter or a compare-and-set), atomic operations guarantee consistency without explicit locks, which can be faster and simpler. When the whole read-modify-write can be expressed as one atomic step, the race condition simply can't occur, because there's no gap for another thread to slip into.
# The problem: read-modify-write is NOT atomic - a thread can interrupt between steps
balance = read_balance() # both threads read 1000 here
if balance >= 600: # both see "enough"
write_balance(balance - 600) # both write 400 → double deduction
# The fix: make the whole operation exclusive (a lock) or atomic
with account_lock: # only one thread at a time inside this block
balance = read_balance()
if balance >= 600:
write_balance(balance - 600)
# now the second thread waits, reads the UPDATED balance, and correctly fails
You'll also hear about two broad locking strategies:
- Pessimistic locking assumes conflicts will happen, so a thread locks the data before touching it - no one else can access it until the thread is done. Safe, but threads wait, which can slow things down.
- Optimistic locking assumes conflicts are rare, so threads don't lock up front - they proceed, and only check at the end whether anyone else changed the data in the meantime. If someone did, the operation is retried. Faster when conflicts are genuinely rare, at the cost of occasional retries.
Which to use depends on how often conflicts actually occur: pessimistic when contention is high, optimistic when it's low.
The critical section: naming the danger zone
There's a precise term for the part of your code where the danger lives: the critical section. A critical section is any block of code that accesses shared data in a way that would break if two threads ran it at the same time - the read-modify-write on the balance is a critical section. The entire discipline of preventing race conditions comes down to one rule: only one thread may be inside a given critical section at a time.
That reframes every solution above. A lock protects a critical section (only the lock-holder is inside it). Synchronization guards critical sections (coordinating who enters). An atomic operation is a critical section compressed into a single indivisible step (so there's no "inside" for a second thread to enter). Once you can spot the critical sections in your code - the places where threads touch shared data - you know exactly where protection is needed and where it isn't. Code that doesn't touch shared data needs no protection at all; only the critical sections do. This is why the skill isn't "lock everything" (which would create the deadlocks and slowdowns we'll discuss) but "identify the critical sections and protect exactly those."
Where race conditions show up in real systems
The bank example is vivid, but race conditions lurk anywhere multiple threads (or processes, or servers) touch shared state. A few common places they appear:
- Counters and totals. Two threads incrementing the same counter can lose an increment (both read the same value, both add one, both write - the count goes up by one instead of two). The same read-modify-write trap as the balance.
- Inventory and bookings. Two customers buying the last item, or booking the last seat, at the same moment - both see it as available, both succeed, and you've oversold. This is the double-deduction problem wearing different clothes.
- Check-then-act patterns. Any code that checks a condition and then acts on it ("if the file doesn't exist, create it"; "if there's enough balance, deduct") is a race waiting to happen if another thread can change things between the check and the act.
- Caches and shared configuration. Multiple threads updating a shared cache or config structure can corrupt it if updates aren't coordinated.
The common thread (so to speak): wherever shared, mutable state is touched by more than one thread concurrently, a race condition is possible. And notice this scales up - the same problem appears not just between threads in one program, but between processes on one machine and even between separate servers hitting a shared database (which is exactly why databases offer transactions and locking, and why the pessimistic-vs-optimistic distinction shows up there too). Race conditions are a concurrency problem at every scale, from two threads to two data centers. The concept you learn here on threads applies all the way up.
The irony: solving race conditions can create deadlocks
Here's the twist, and it's the setup for the next post in this series. Locks and synchronization solve race conditions - but improper use of these very solutions introduces a brand-new problem: the deadlock.
A deadlock is where two or more threads end up waiting on each other indefinitely, each holding a resource the other needs, neither able to proceed - and the program simply freezes. Imagine Thread A holds Lock 1 and wants Lock 2, while Thread B holds Lock 2 and wants Lock 1. Each waits for the other to release, forever. The very locks you added to prevent corruption can, if misused, cause your program to hang completely.
So concurrency is a balancing act: too little coordination and you get race conditions (silent corruption); too much or careless coordination and you get deadlocks (frozen programs). Navigating between them is the real art of concurrent programming - and deadlocks are exactly what we'll tackle next.
Interview: Race conditions are a favorite interview topic because they test whether you truly understand concurrency. Be ready to define one crisply: it's when multiple threads read and modify shared data concurrently, and the result depends on unpredictable execution order - making it non-deterministic and often wrong. The strong answer includes why they're dangerous (silent, no crash, intermittent, hard to reproduce) and how to prevent them (locks for exclusive access, synchronization to coordinate, atomic operations for indivisible updates). Bonus depth: mentioning pessimistic vs. optimistic locking, and that locks themselves can cause deadlocks if misused. Walking through a concrete example like the double-deduction shows you can reason about the interleaving, which is exactly what interviewers want.
The bottom line
A race condition happens when multiple threads read and modify shared data at the same time, and the outcome depends on the unpredictable order they execute - making results non-deterministic and frequently wrong. The double-deduction example shows the danger: two ₹600 transactions both read a ₹1,000 balance before either writes, both succeed, and ₹1,200 leaves an account that held ₹1,000 - with no error, no crash, just silently corrupted data. That silence, plus their intermittent and hard-to-reproduce nature, makes race conditions among the most dangerous bugs there are.
The fixes ensure only one thread touches shared data at a time: locks (exclusive access), synchronization (coordinated execution), and atomic operations (indivisible steps), with pessimistic or optimistic locking strategies depending on contention. But there's a catch - misusing those very locks can cause deadlocks, where threads freeze waiting on each other forever. That's the next problem in the series. We cover concurrency, race conditions, and locking across System Design Fundamentals.
Go deeper
- Race condition (Wikipedia) - the concept and its causes.
- Lock (computer science) (Wikipedia) - how locks provide exclusive access.
- Synchronization (computer science) (Wikipedia) - coordinating threads around shared data.
- Linearizability (Wikipedia) - the theory behind atomic, indivisible operations.