Skip to content
RungsySign in

Race Conditions

Two things interleaving in an order you never tested — the bug class that only shows up in production.

45 minDifficulty 4/5systems · reliabilityAI-writtenWritten by a model on 16 August 2026 and not yet read by a person. Checked automatically: schema, the pedagogical rules the hand-written material is held to, and every diagram parsed for real.

Before this

Why this exists

Two requests hit "withdraw $50" on an account with a $100 balance, both read the balance as $100 at nearly the same instant, both compute $100 - $50 = $50, and both write $50 back — the account should be at $0, but it's at $50, and $50 evaporated with no error, no exception, and no log entry pointing at the bug. This is a race condition: the outcome depends on an interleaving order that only shows up under real concurrent load, which is exactly why it's almost never caught in testing.

The mental model

A race condition exists wherever two or more operations READ, then separately WRITE, shared state, with a gap between the read and the write where another operation can sneak in. The bug isn't in either operation's logic individually — both computed correctly given what they read — it's in the GAP, where the assumption 'nothing changed between my read and my write' turns out to be false.

How it works

The bug is invisible in code review and single-threaded testing

Reading the withdrawal function's code line by line, every line looks correct: read balance, subtract amount, write balance. The bug only exists in the SPACE between two concurrent executions of this same function — a code reviewer reading it once, or a test running it once, sees nothing wrong, because the race requires two executions actually overlapping in time to manifest.

Atomic operations close the gap by making read-then-write indivisible

Instead of 'read balance, compute new value, write new value' as three separate steps another operation can interleave into, an atomic decrement — `UPDATE accounts SET balance = balance - 50 WHERE id = ?` — performs the read and write as ONE indivisible database operation, with no gap for another transaction to sneak into.

Locks serialize access, at the cost of one operation waiting for another

A lock lets only one operation hold access to a piece of shared state at a time — a second operation trying to acquire the same lock waits until the first releases it. This eliminates the race by making the two operations happen strictly one after another instead of overlapping, at the cost of the second operation's latency, which now includes waiting.

Optimistic concurrency control detects the race after the fact and retries

Rather than preventing overlap with a lock, an optimistic approach lets both operations proceed but checks, at write time, whether the data changed since it was read (via a version number or timestamp) — if it did, the write is rejected and the operation retries against the current data. This avoids the cost of locking for the common case where conflicts are rare, at the cost of occasionally needing a retry.

The mechanism

Two operations each read the same shared value before either has written back its update. Both compute their new value based on that same stale read. Whichever writes LAST overwrites the other's update entirely — not because of any bug in the arithmetic, but because neither operation knew about the other's concurrent read and write.

sequenceDiagram
  participant A as Request A
  participant DB as Database (balance=100)
  participant B as Request B
  A->>DB: read balance (100)
  B->>DB: read balance (100)
  A->>DB: write balance = 50
  B->>DB: write balance = 50
  Note over DB: Final balance is 50, not 0 - $50 lost
Diagram source for Race Conditions.

What people get wrong

A race condition means the code has a logic error.
Each individual execution's logic is entirely correct given what it read — the bug exists specifically in the possibility of two executions overlapping in time, which single-threaded review or testing structurally cannot surface. This is exactly why race conditions survive code review and pass all normal tests: there's nothing wrong to spot by reading the code once, because the bug only exists under actual concurrent execution.
Race conditions only matter for multi-threaded or genuinely parallel systems.
Race conditions occur any time operations can interleave unpredictably, which includes purely concurrent (not parallel) systems — two async requests handled by a single-threaded Node.js server can race against each other for a shared resource like a database row, with no multiple threads involved at all. This misconception leads teams using single-threaded async runtimes to assume they're immune to races, when the actual requirement is just 'can two logical operations overlap', which async concurrency alone satisfies.
Adding a delay (like a small sleep) between the read and write is a reasonable way to 'fix' a suspected race condition.
A delay might reduce the OBSERVED frequency of the race in casual testing by changing timing, but it doesn't close the actual gap where interleaving can occur — under real, higher-concurrency production load, the race can and will still happen. This is a classic 'fix' that appears to work because it makes the race statistically rarer during testing, while leaving the actual structural vulnerability completely intact.

When not to use it

The operation is a simple increment/decrement on a single value, supported natively by the data store.
An atomic operation provided by the database (like `UPDATE ... SET x = x + 1`) — this is simpler and has less overhead than a full lock for this specific, common case.
Conflicts between concurrent operations are rare, and you want to avoid the cost of locking for the common, non-conflicting case.
Optimistic concurrency control (a version check at write time), accepting the occasional retry cost in exchange for avoiding lock overhead the vast majority of the time.

Terms

Race condition
A bug where the outcome depends on the unpredictable relative timing of two or more operations accessing shared state, typically from a read-then-write gap.
Atomic operation
An operation that completes as a single, indivisible step from the perspective of other concurrent operations, with no gap in which another operation could interleave.
Optimistic concurrency control
A strategy that allows concurrent operations to proceed without locking, detecting conflicts at write time (via a version check) and retrying if one occurred.
Critical section
A portion of code that accesses shared state and must not be executed by more than one concurrent operation at the same time.

In an interview

Two API requests to 'increment a like counter' arrive at nearly the same time, and the final count is off by one. What's happening, and how would you fix it?

  • both requests read the same starting count before either writes back, so both compute count+1 from the same stale value
  • whichever write happens last overwrites the other, effectively losing one increment
  • fix with an atomic database increment (UPDATE ... SET count = count + 1) rather than read-modify-write in application code, or use a lock/optimistic version check

Can you recall it?

Why can a race condition pass all tests and code review, yet still cause real data corruption in production?

Also part of

This idea matters in more than one area — which is usually why it matters.

Keep track of this

Add Concurrency to your map and Rungsy will schedule reviews so you actually remember it.