Race Conditions
Date: 2026-08-17
A bug whose outcome depends on the order two operations happen to finish in. They pass every test, work on fast connections, and appear in production on the slow ones — which is why they’re diagnosed from user reports rather than from stack traces.
A race condition occurs when the correctness of a result depends on timing that isn’t controlled. Nothing errors; the wrong thing simply wins.
The shape, in a search box
user types "sh" → request A sent
user types "shoe" → request B sent
FAST PATH SLOW PATH
A returns (shirts) B returns (shoes)
B returns (shoes) A returns (shirts)
↓ ↓
shows shoes ✓ shows SHIRTS ✗
for the query "shoe"
Nothing failed. Both requests succeeded; the older one just landed last and overwrote the newer result. On a fast connection this almost never happens, which is why it survives development.
The fixes, in order of preference
1 CANCEL the stale request
AbortController — the request stops,
its handler never runs
2 IGNORE stale responses
tag each request; discard any
response that isn't the latest
3 DEBOUNCE the trigger
fewer requests, smaller window
← reduces the odds, doesn't remove
the race
Debouncing alone is not a fix. It makes the bug rarer, which makes it harder to find. Cancel or discard.
The other shapes
Read-modify-write. Two operations read the same value, both modify their copy, and the second write erases the first:
stock = 10
A reads 10 B reads 10
A computes 10 − 1 B computes 10 − 3
A writes 9 B writes 7
↑ A's sale is gone
The fix is not application code — it’s the database doing the arithmetic (SET stock = stock - 1), or a transaction with appropriate isolation, or optimistic locking with a version column — Transactions and ACID.
Check-then-act. The gap between checking a condition and acting on it:
if (!(await userExists(email))) // check
await createUser(email) // actTwo requests can both pass the check before either creates the user.
The fix is a unique constraint in the database, and handling the conflict. A check in application code cannot close this gap.
Component teardown. An async result arriving after the thing that asked for it has gone — a state update on an unmounted component, or a handler writing into a DOM node that no longer exists.
Double submission. A user clicks twice; two orders. Disable on submit, and enforce with an idempotency key server-side, because the disable is a UI courtesy rather than a guarantee — Idempotency.
Why they’re hard
- They pass tests, because tests are fast and deterministic
- They don’t reproduce locally, where latency is near zero
- They present as data problems — wrong stock, duplicate order, stale UI — so they get investigated as data bugs
- The user can’t describe them. “It showed the wrong thing” arrives without the sequence that caused it
The diagnostic habit: when a bug is intermittent and involves two things that can happen in either order, assume a race before assuming anything else. Throttling the network in DevTools to a slow profile makes most front-end races reproducible immediately.
Not a threading problem here
Genuine data races — two threads writing the same memory simultaneously — can’t happen in JavaScript, because there’s one thread — Concurrency and Parallelism.
What remains is ordering, and it’s just as capable of showing a customer the wrong price. Single-threaded runtimes remove a class of race and leave this one entirely intact, which is worth being clear about — the safety guarantee is narrower than it’s often described.
Prevention worth building in
AbortControlleron every request tied to changing input — search, filters, autocomplete- Let the database enforce uniqueness and arithmetic, rather than the application
- Idempotency keys on anything that creates or charges
- Cancel on teardown in every component that fetches
- Test on a throttled connection before shipping anything with concurrent requests