What Is a Race Condition? (With Examples)
A race condition is a bug where the outcome depends on the unpredictable timing or interleaving of two or more operations that access the same shared state. Here are concrete examples, why they cause flaky tests, and how to prevent them on the frontend and backend.
Definition
A race condition is a bug where a program's outcome depends on the unpredictable timing or interleaving of two or more operations that access the same shared state. When they run in the assumed order it works; when they interleave differently you get a stale value, a lost update, or a crash.
The standards-body phrasing is precise. MDN defines a race condition as behavior where 'the output is dependent on the sequence or timing of other uncontrollable events.' The two load-bearing words are uncontrollable and timing: your code assumes an order of operations, but nothing actually enforces that order, so the real order is decided by whatever happens to be faster at runtime — the network, the scheduler, the disk, the user's click speed. When the fast path lines up with your assumption, the bug hides. When it does not, the program produces a wrong result even though every individual line is correct.
The shared state is the other half of the definition. A race needs at least two operations and something they both read or write — a variable, a piece of React state, a row in a table, a file, a cache entry. The general concept calls the overlapping window a critical section: the stretch of code where concurrent access to that shared resource must be serialized, or the result becomes undefined. If two operations never touch the same state, they can interleave all they like and nothing breaks. The bug only exists where timing and sharing meet.
Four concrete examples
1. Stale async response (last-write-wins). Two requests update the same state, and the older one happens to return last, so its stale result clobbers the newer one. This is the single most common race in frontend code — a search box, a tab switch, a filter — because responses do not return in the order they were sent.
// BUG: whichever request RESOLVES last wins, not whichever was SENT last.
function onQueryChange(query: string) {
fetch(`/api/search?q=${query}`)
.then((r) => r.json())
.then((results) => {
// Type 'react' (slow) then 'redux' (fast): 'redux' returns first,
// then the slow 'react' response lands and overwrites it.
setResults(results); // stale response wins
});
}2. Check-then-act (TOCTOU). You read a value, then act on it — but the value can change between the check and the act. Time-of-check to time-of-use is the formal name for this race, and it is the root of countless 'but I just checked that it existed' bugs.
// BUG: another request can grab the last seat between the check and the act.
async function reserveSeat(eventId: string) {
const seats = await getAvailableSeats(eventId); // time of CHECK
if (seats > 0) {
// ...another request runs here and books the last seat...
await bookSeat(eventId); // time of USE — now oversold
}
}3. Double-submit before the button disables. The user double-clicks 'Pay' faster than the handler flips disabled, so two identical requests go out. Two charges, one order.
// BUG: the second click fires before React re-renders with disabled=true.
function CheckoutButton() {
const [submitting, setSubmitting] = useState(false);
async function onClick() {
if (submitting) return; // guards later clicks, but the FIRST two race
setSubmitting(true); // state update is async — not applied instantly
await pay(); // a fast double-click can enter twice
}
return <button onClick={onClick}>Pay</button>;
}4. Read-modify-write without a transaction. Two backend requests read a counter, both increment it in application code, and both write back. One increment is silently lost — the classic lost-update race on a database row.
// BUG: two concurrent requests both read 41, both write 42. Lost update.
async function incrementViews(postId: string) {
const post = await db.post.findUnique({ where: { id: postId } });
const next = post.views + 1; // both read the same value
await db.post.update({ // both write it back
where: { id: postId }, data: { views: next },
});
}Why race conditions are so hard
Race conditions are the debugging nightmare that a stack trace cannot solve, and the reason is structural: the bug is in the timing, not in any single line. Reproduce it and the timing shifts and it vanishes. That is what 'works on my machine' actually means for a race — your machine happens to schedule the operations in the order your code assumed, so the fast path always wins locally, while a loaded server or a slower network flips the order and the bug appears. The failure is intermittent by definition, so it slips past code review, past a green test suite, and into production, where it shows up as a mysterious one-in-a-thousand wrong number.
How to prevent them
Every fix comes down to the same idea: stop leaving the order to chance and make it explicit, so concurrent access to shared state is serialized or made safe. The specific tool depends on where the race lives.
On the backend, wrap read-modify-write sequences in a database transaction so the read and write are atomic, or push the arithmetic into a single atomic operation the database serializes for you. Use optimistic locking — a version column that makes a stale write fail loudly — or a lock/mutex for a true critical section. Add an idempotency key so a retried or duplicated request cannot apply its effect twice.
// FIX: one atomic UPDATE. The database serializes concurrent increments —
// no read-modify-write window, so no lost update.
await db.post.update({
where: { id: postId },
data: { views: { increment: 1 } },
});
// Or optimistic locking: only write if the row is still the version we read.
// UPDATE post SET views = 42, version = version + 1
// WHERE id = ? AND version = 41; -- 0 rows updated => retryOn the frontend, disable the submit button on the first click (and dedupe server-side too, since a determined user can beat any UI guard). Debounce rapid input so you fire one request instead of ten. Sequence dependent steps with await so step two never starts before step one finishes. And for the stale-response race, cancel in-flight requests with AbortController so an older response can never overwrite a newer one.
// FIX: abort the previous request before starting a new one. A stale
// 'react' response is aborted the moment 'redux' fires — it can't win.
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then((r) => r.json())
.then(setResults)
.catch((e) => { if (e.name !== 'AbortError') throw e; });
return () => controller.abort(); // cancel on the next keystroke
}, [query]);How this shows up in a real BugMojo bug report
Here is the honest limit of a stack trace on a race condition. A trace tells you where code ran and the call path that got there — but a race's root cause is the interleaving of two operations, and a single trace only ever captures one of them at one moment. The state was corrupted by the order in which requests resolved, and that order does not appear in a stack frame. So the frame looks innocent and the bug looks impossible.
In a BugMojo report the trace does not arrive alone. The browser extension captures the failure with its surrounding state — an rrweb session replay, the console output, and the full network timeline — so you can literally see the two requests overlap: GET /api/search?q=react sent first, GET /api/search?q=redux sent second, and the react response resolving last and overwriting the newer one. That interleaving is the whole bug, and it is exactly what a stack trace alone can never reveal. Pair that timeline with a session replay and 'it randomly shows the wrong results' becomes 'the older request wins the race — cancel it with AbortController.'
| Feature | Capability | BugMojo | Stack trace alone |
|---|---|---|---|
| Shows where code failed | — | ✓ | ✓ |
| Network timeline showing request interleaving | — | ✓ | — |
| rrweb session replay of the exact interaction | — | ✓ | — |
| Console + network captured with the failure | — | ✓ | — |
| Captured bug bundle handed to an AI agent over MCP | — | ✓ | — |
BugMojo captures the failing session with its rrweb replay, console, and full network timeline — so you can watch the two requests interleave — then hands the whole bundle to Claude Code or Cursor over MCP, so your agent reads the actual ordering behind the race and can fix it.
Install the extensionFrequently asked questions
Frequently asked questions
Sources
- Race condition — "the behavior of a system where the output depends on the sequence or timing of other uncontrollable events" (MDN Glossary) — MDN Web Docs (2026)
- Race condition — critical sections, data races, and shared-resource timing (overview) — Wikipedia (2026)
- AbortController — signal in-flight fetch requests to cancel so a stale response cannot win — MDN Web Docs (2026)
- Time-of-check to time-of-use (TOCTOU) — the check-then-act race where state changes between the two steps — Wikipedia (2026)
Get bug-tracking insights, weekly.
Engineering deep-dives, QA playbooks, and honest tool comparisons. No spam — unsubscribe in one click.

