Race Conditions
A race condition is a bug that depends on timing. Step through counter++ as three operations interleaving across two threads, see why races still happen in single-threaded JavaScript, and fix them with atomics, locks and database constraints.
🎯 What you'll learn
- What a race condition is (timing, not syntax)
- Why counter++ isn't atomic
- Data races vs race conditions; where races hide
- Fixes, async races in single-threaded runtimes, and hardware atomics
📚 Before you start
1Definition — A Property of Timing, Not of Code Reading Wrong
A race condition is a bug where the correctness of a result depends on the relative timing or interleaving of operations that access shared, mutable state — not on any operation individually being wrong. Every step in the buggy execution is individually correct; the bug only appears from a specific ordering of otherwise-correct steps happening across concurrent tasks.
This makes race conditions uniquely nasty: they are non-deterministic (the same code can run correctly a thousand times and fail on the thousand-and-first, depending on scheduling), they often disappear under a debugger (adding a breakpoint changes timing enough to hide the interleaving that triggers the bug), and they require genuine concurrent execution — a topic this stack now assumes from Concurrency vs Parallelism — over shared mutable state to exist at all.
2The Classic Example: counter++ Is Three Operations, Not One
counter++ looks like a single atomic step in source code. It is not — it compiles to (at least) three distinct machine-level operations: read the current value, modify it (add 1), write the new value back. Any of those three steps can be interrupted by another thread doing the same three steps on the same memory location.
Thread A: local_A = counter // read: local_A = 0
Thread B: local_B = counter // read: local_B = 0 (BEFORE A writes back!)
Thread A: local_A = local_A + 1 // modify: local_A = 1
Thread A: counter = local_A // write: counter = 1
Thread B: local_B = local_B + 1 // modify: local_B = 1 (using the STALE 0 it read)
Thread B: counter = local_B // write: counter = 1 (clobbers A's update!)
Two increments happened. counter ends at 1, not 2. One update is LOST.This is called a lost update, and it is the canonical race condition: nothing crashed, no exception was thrown, every individual line executed exactly as written — the bug exists purely in the interleaving of steps 2 and 3 above. Note this requires genuine simultaneity or preemption between the read and write of at least one thread — exactly the parallel or preemptively-scheduled execution described in the previous topic's Under the Hood section.
3Data Race vs. Race Condition — Related but Distinct
These terms are often used interchangeably but mean different things:
- A data race is a specific, narrower condition: two or more threads access the same memory location concurrently, at least one access is a write, and there is no synchronization ordering the accesses. In languages with a formal memory model (C++, Rust, Java), an unsynchronized data race is technically undefined behavior — the compiler and CPU are allowed to reorder, cache, or tear the access in ways that produce results stranger than "just" a lost update.
- A race condition is the broader, higher-level bug: the outcome depends on timing. A race condition can exist without a technical data race — the classic example is a TOCTOU (time-of-check to time-of-use) bug, where a check and a later action are individually safe and even properly synchronized in isolation, but another process can act in the gap between them:
// TOCTOU race — no shared-memory data race, still a race condition:
if (!fileExists(path)) { // CHECK: file doesn't exist
// <-- another process creates the file HERE, in the gap -->
createFile(path); // USE: overwrites the file that now exists
}Every concurrency-safety technique in section 5 addresses race conditions in general; only some of them (locks, atomics) specifically target true data races on shared memory.
4Where Race Conditions Hide in Real Systems
- Check-then-act: checking a condition, then acting on it, with a gap where another task can invalidate the check (the TOCTOU pattern above; also "check if a row exists, then insert it" without a unique constraint).
- Read-modify-write on shared counters/balances: the
counter++pattern generalized — account balance updates, inventory decrements, rate-limit counters. - Double-submit / duplicate processing: a user double-clicks "Pay," or a network retry resends a request, and two concurrent handlers both act on it before either has recorded that it happened (this is exactly what idempotency, covered in the Failure Handling topic, is designed to prevent).
- Cache stampede / thundering herd on cache-miss: many concurrent requests all miss a cold cache simultaneously, all check "is it cached?", all get "no," and all proceed to regenerate the same expensive result at once.
- Lazy initialization without guarding: two threads both check "has the singleton been created?", both see "no," and both construct it — producing two instances where the code assumed exactly one.
5Fixing Race Conditions
| Technique | How it works | Trade-off |
|---|---|---|
| Mutual exclusion (locks/mutexes) | Only one thread may hold the lock and execute the guarded section at a time. | Simple to reason about; risks deadlock (next topic) and serializes contended code, hurting throughput. |
| Atomic operations | Hardware-guaranteed indivisible read-modify-write (e.g. atomic increment, compare-and-swap). | Fast, no blocking; only covers single-variable updates, not multi-step invariants. |
| Immutability | Never mutate shared state; produce new values instead. | Eliminates the possibility of a write-write or read-write race entirely — the strongest fix, where applicable. |
| Message passing / actor model | Threads never share memory directly; they communicate by sending messages to a single owner of the state, which processes them one at a time. | No shared-memory race by construction; adds messaging overhead and different failure modes. |
| Unique constraints / conditional writes at the data layer | Push the check-then-act atomicity down into the database (INSERT ... ON CONFLICT, a unique index, an optimistic-concurrency version column). | Turns an application-level race into a single atomic operation the datastore guarantees; requires the datastore to support it. |
6Race Conditions Still Happen in Single-Threaded Runtimes
A common misconception is that a single-threaded model (like JavaScript's — see languages/javascript/34-concurrency) is immune to race conditions because "only one thing runs at a time." That protects against races within a single synchronous block of code (nothing can interrupt you mid-expression), but it does not protect against races across await boundaries, because control genuinely returns to the event loop at each await and another task can run — including one that mutates the same state:
// Async TOCTOU race — single thread, still racy:
async function reserveSeat(seatId) {
const seat = await db.getSeat(seatId); // CHECK — control yields here; another
// call to reserveSeat can now run fully
if (seat.available) {
await db.markReserved(seatId); // USE — may double-book if two calls
} // both passed the check before either wrote
}Two concurrent calls to reserveSeat can both read available: true before either writes reserved, double-booking the seat — on a single thread, with no true data race, purely because of await-boundary interleaving (see languages/javascript/29-promises and 27-event_loop for exactly how the event loop schedules the resumption of each await).
Under the HoodWhat "Atomic" Actually Guarantees at the Hardware Level▾
The CPU-level primitive: compare-and-swap
Fixing a race condition on a single value ultimately relies on a hardware instruction that performs read-modify-write as one indivisible step the CPU guarantees cannot be interrupted or interleaved with another core's access to the same memory location. The most common such primitive is compare-and-swap (CAS):
CAS(address, expected, new_value):
atomically:
if *address == expected:
*address = new_value
return true // success — no one else touched it in between
else:
return false // someone else changed it first — caller must retryOn x86, this is implemented via the LOCK CMPXCHG instruction — the LOCK prefix asserts exclusive ownership of that memory location's cache line for the duration of the operation, blocking other cores from accessing it mid-instruction. On ARM, the equivalent is a load-linked/store-conditional (LL/SC) pair: load the value and "mark" the address, then attempt to store only if nothing else wrote to that address since the load, retrying otherwise. Both approaches guarantee the same property: no other core can observe or produce an intermediate state partway through the read-modify-write.
Building an atomic increment from CAS
A correctly atomic increment (unlike the buggy counter++ in section 2) is built as a CAS retry loop:
atomicIncrement(address):
loop:
old = *address // read current value
new = old + 1
if CAS(address, old, new): return new // succeeds only if nothing changed it since the read
// else: someone else updated it between our read and our CAS — retry with the fresh valueThis is the crucial difference from section 2's broken version: the write is conditional on the value being unchanged since the read. If another thread interleaves an update in between, the CAS fails, and the loop retries against the new value instead of blindly overwriting it — the update is never silently lost.
Mutexes are built on top of this, not the other way around
A mutex (mutual exclusion lock) is a higher-level construct implemented using CAS: acquiring the lock is typically a CAS from "unlocked" to "locked," retried (optionally spinning briefly, then yielding) until it succeeds; releasing is a plain write back to "unlocked," possibly waking a waiting thread. Modern OS mutex implementations (Linux futex, "fast userspace mutex") optimize the common uncontended case entirely in userspace with a single CAS and only fall into a genuine kernel-level sleep/wake syscall when there's actual contention — because kernel transitions are far more expensive than a single CAS instruction.
A semaphore generalizes this to N permits
Where a mutex allows exactly one holder, a semaphore maintains an integer count of available "permits" and allows up to that many concurrent holders; acquiring atomically decrements the count (blocking if it would go below zero), releasing atomically increments it. A mutex is a semaphore with a maximum count of 1. Semaphores are the natural fit for bounding concurrency to a resource limit (e.g., "at most 10 concurrent connections to this database"), which reappears as the bulkhead pattern in the Resilience Patterns topic.
What atomic does NOT guarantee
Atomicity guarantees a single memory location's read-modify-write is indivisible. It says nothing about ordering guarantees for other, unrelated memory locations around it — that requires a separate concept, memory barriers/fences, which control how a CPU and compiler may reorder reads and writes relative to each other across cores (this is why languages with a memory model define "happens-before" relationships explicitly, rather than assuming atomic operations alone make multi-variable invariants safe). Protecting an invariant that spans multiple variables (e.g., "debit account A and credit account B must both happen or neither") needs a lock or transaction around the whole sequence — no single atomic instruction covers it.
⚠️ Common Pitfalls
- Assuming an operation is atomic because it looks like one line —
counter++,x += 1, and even some higher-level "atomic-looking" library calls can hide multiple steps; verify explicitly rather than assuming. - Believing single-threaded (JS-style) code can't have race conditions — true within one synchronous block, false across
await/callback boundaries where another task can interleave (section 6). - Fixing the symptom by adding a delay ("it works if I
sleephere") — this doesn't fix the race, it only makes the buggy interleaving less likely, which reappears under different load, hardware, or timing (and is famously hard to reproduce and debug once it does). - Locking too narrowly, protecting only part of a multi-step invariant — e.g., locking around the read and separately around the write of a check-then-act sequence, leaving the gap between them unprotected; the lock must span the entire read-decide-write sequence.
- Using a data race fix (an atomic/lock) for a problem that isn't a data race — a TOCTOU race across separate systems (e.g., two microservices) can't be fixed with a local mutex; it needs a distributed coordination mechanism (a unique constraint, distributed lock, or idempotency key — see Failure Handling and Resilience topics).
📋 Quick Reference
Race condition: outcome depends on TIMING of access to shared mutable state
Data race: race condition ON THE SAME MEMORY LOCATION, unsynchronized, ≥1 write — undefined behavior in most memory models
Lost update: classic race — read-modify-write interleaves, one update silently overwritten
Fixes: mutex (mutual exclusion) | atomics/CAS (single-variable) | immutability (eliminates it) | message passing | DB unique constraint
CAS(address, expected, new): atomically swap only if unchanged since read — retry loop if it fails
Mutex ⊂ built on CAS; futex = fast path in userspace, kernel only on real contention
Semaphore = mutex generalized to N permits (bulkhead pattern uses this)
Atomicity ≠ ordering guarantee for OTHER variables — that needs memory barriers / a lock spanning the whole invariant🧠 Key takeaways
- A race condition = correctness depends on interleaving; tests rarely catch it.
counter++is read-modify-write: three steps that can interleave.- Races hide in check-then-act (if not exists then insert), caches, files and async code.
- Fix with atomic operations, locks, database constraints/transactions, or eliminating shared mutable state; JS still races across awaits.
🎤 Interview Q&A
Q1What is a race condition?core▾
A bug where the outcome depends on the timing of concurrent operations on shared state, e.g. two threads doing count++ (read-modify-write) and losing an update. It appears intermittently and is hard to reproduce.
- Data race vs race condition? A data race is unsynchronized concurrent access to memory with at least one write; a race condition is a broader logic timing bug.
Q2How do you prevent race conditions in code?core▾
Avoid shared mutable state (immutability, confinement, message passing), use atomic operations (Interlocked, AtomicInteger), locks/mutexes around critical sections, or concurrent collections. Keep critical sections small.
- Why is
if (!map.containsKey(k)) map.put(k, v)racy? Check-then-act: another thread can insert between the check and the put; useputIfAbsent.
Q3How do race conditions appear in web applications and databases?core▾
Two requests reading a balance and writing it back (lost update), double submissions creating duplicate orders, or check-then-insert uniqueness checks. Fix with atomic SQL updates, unique constraints, SELECT … FOR UPDATE, optimistic locking (version columns) and idempotency keys.
- Optimistic locking in SQL?
UPDATE … SET …, version = version + 1 WHERE id = ? AND version = ?and retry if 0 rows updated.
Q4What's a TOCTOU bug?senior▾
Time-of-check to time-of-use: a condition checked earlier may no longer hold when acted upon, e.g. checking a file exists then opening it, or checking stock then reserving it. Make check-and-act atomic.
- Security relevance? Attackers can exploit the gap, e.g. swapping a file for a symlink.
Q5How do you find race conditions?core▾
Code review of shared state, thread sanitizers (TSan, Go's -race), stress and concurrency tests with many iterations, deterministic simulation testing, and load tests that fire concurrent identical requests.
- Why do they hide in tests? Tests run with little concurrency and favourable timing.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.