Two requests hit the same “withdraw $100” endpoint half a millisecond apart. The account has $150. Both requests read that balance, both see enough funds, both approve, both subtract $100. The account ends at -$50, and no branch in the code should have allowed it. That is a race condition: the output depends on which operation happens to finish first, and “first” was never something the code decided.
What causes a race condition?
You need two ingredients at once: shared mutable state, and more than one path of execution touching it without coordination. The paths can be threads, async tasks, processes, or two servers hitting the same database row. Take away either ingredient and the bug disappears. A single thread can’t race against itself, and immutable state can’t be corrupted by being read twice.
The classic shape is check-then-act: read a value, decide something based on it, then act on that decision. If another path runs its own read-decide-act sequence in the gap between your read and your write, both make a decision that was valid for a moment that no longer exists by the time either one writes.
A shared counter shows the mechanism with nothing else in the way. counter += 1 looks like one operation. It is three: read the current value, add one, write the result back. If two threads run that sequence on a counter starting at 0, and both read before either writes, both compute 1 and both write 1. One increment is gone. No exception, no log line.
| |
Run it a few times. The shortfall isn’t the same every time, and that is the second signature of a race condition: it doesn’t reproduce reliably, because it depends on scheduling that differs from run to run and machine to machine.
How do you fix a race condition with a lock?
Make the read-modify-write sequence atomic, so that once one thread starts it, no other thread can interleave. A lock does that by letting only one thread hold it at a time. Every other thread calling acquire() blocks until the holder calls release().
| |
with lock: acquires on entry and releases on exit, including when an exception is raised inside the block. You never end up with a lock nobody releases.
The mistake that keeps the bug alive after a lock is added: locking the write but not the check. This dosage function reads as safe and isn’t:
| |
Two threads can both pass the check before either takes the lock. Both add their amount, and the total goes past the limit. The lock has to wrap the check and the act as one critical section, not only the line that looks dangerous:
| |
Under Python’s GIL the narrow version often happens to work. That’s why it survives code review: it passes every test on your laptop, then fails under production load or the day you move to a free-threaded build.
Can async JavaScript have race conditions?
Yes. JavaScript runs your code on one thread in the browser and in Node’s main execution, but the failure mode comes back wherever two awaits can finish in an order you didn’t plan for. A search box is the usual case. The user types “cat”, a request goes out, they keep typing “caterpillar”, a second request goes out. If the first request is slower, its response lands after the second one and replaces the newer results with stale ones.
| |
Each call stamps itself with an ID before awaiting anything. When the response comes back, it renders only if it is still the most recent request. AbortController is the other common tool: it cancels the stale request outright instead of letting it finish and throwing the result away, which also saves the network round trip.
The subtler version has no network call at all, only a shared variable spanning an await:
| |
Both calls read totalLength as 0 before either await resolves. Both compute their new total from the same stale starting point, and the second write clobbers the first. The read and the write sit on opposite sides of a suspension point, and anything can run during a suspension. If that scheduling isn’t second nature yet, /en/posts/javascript/javascript-async-await-event-loop/ walks through what the event loop does at each await. The fix: resolve the value first and fold it into the shared state in one synchronous step (const len = await getPageLength(n); totalLength += len;), or run the additions sequentially instead of with Promise.all.
How do you prevent race conditions in a database?
Move the counter into a row and the check-then-act gap gets wider, because it now spans a round trip to the database and back. Two application servers, each running “read balance, validate, write new balance” against the same row, can both read $150 and both approve a $100 withdrawal. That’s the opening example, at the SQL level.
Two fixes, with different costs.
Pessimistic locking takes the row lock up front. In PostgreSQL that’s SELECT ... FOR UPDATE:
| |
A second transaction trying to SELECT ... FOR UPDATE the same row blocks until the first one commits or rolls back. Correct, but every concurrent request for that row now queues behind the lock. That costs latency under contention, and it can deadlock if two transactions lock rows in opposite order. Keep the transaction short: here the row is found by primary key, which is an index lookup. Filter on an unindexed column instead and the lookup turns into a scan, which stretches every transaction while the others wait (the mechanics are in /en/posts/databases/how-database-indexes-work/).
Optimistic locking skips the lock and checks for a conflict at write time, usually with a version column:
| |
If another transaction already updated the row, version is no longer 7, zero rows match, and your application sees that and retries with the fresh value. This scales better when conflicts are rare. It only works if the version check and the write happen in the same statement. Read the version, then issue a separate UPDATE based on it, and you’ve reopened the gap you were trying to close.
What is the difference between a race condition and a deadlock?
Both get filed as “concurrency bugs,” and they fail in opposite ways:
| Race condition | Deadlock | |
|---|---|---|
| What happens | Execution finishes, with a wrong or inconsistent result | Execution stops; nothing finishes |
| Cause | Unsynchronized access to shared state | Two or more locks waited on in a cycle |
| How you notice | Numbers that don’t add up, data that’s “sometimes” wrong | The program or request hangs |
| Typical fix | Locks, atomic operations, or removing shared mutable state | Consistent lock ordering, timeouts |
A fix for one can introduce the other. Wrapping a check-then-act bug in a lock removes the race. If that code also takes a second lock, in a different order than another code path does, you’ve traded silent data corruption for a hang. Acquire locks in the same order everywhere, and keep the locked section as short as correctness allows.
How do you detect race conditions before production?
Races rarely show up in a single-threaded test running alone on your laptop. They are a common cause of flaky tests, the kind discussed in /en/posts/testing/unit-vs-integration-vs-end-to-end-tests/. A test that fails one run in fifty under CI load and passes every time you re-run it locally deserves suspicion before you write it off as “flaky.”
Go ships a detector for this. Compile or test with -race and the runtime instruments memory accesses, flagging any case where two goroutines touch the same variable concurrently and at least one of them writes.
| |
It only catches races that actually execute during the run, so the closer your tests are to a real concurrent workload, the more it finds. It also multiplies CPU and memory use several times over, so it belongs in CI, not in the production binary. Python and Java have nothing built in that matches it, which is one more reason to rely on locks and atomic database operations rather than test your way to confidence.
Caches hide the same bug. Two requests checking the same key for a stale value and then setting it is check-then-act again, and it’s a gap the cache-aside pattern in /en/posts/backend/redis-caching/ has to account for.
Before you reach for a lock, though, ask whether the shared mutable state needs to exist at all. A balance updated only through a single atomic statement like UPDATE accounts SET balance = balance - 100 WHERE id = 42 AND balance >= 100 has no gap between check and act, because the database does both in one step. The cheapest fix for a race condition is deleting the second path that was racing against the first, so start your next review by listing every place that writes to the state in question.