09 · Concurrency

Race Conditions

8 min read · 5 questions · 1 exercise

A race condition is a bug whose outcome depends on the order two pieces of work touched the same state, an order nothing in your code ever specified.

The view counter is wrong. Not wildly wrong — a hundred thousand page views recorded as ninety-nine thousand and something, and only on the busy days. The code that maintains it is three lines long: read the count, add one, write it back. You read those three lines a dozen times looking for the off-by-one; there isn’t one. The arithmetic is correct, and the counter is still losing numbers.

Picture a paper tally sheet pinned to the wall of a busy shop, and two clerks who each record a sale on it. The first glances at the sheet and sees 5. The second glances a moment later and also sees 5, because the first clerk is still reaching for the pen. Both cross out the 5 and write 6. Two sales happened, the sheet says one, and neither clerk did anything wrong: both glances landed before either pen did. That is a race condition — two things touching the same state, with the result decided by an order that nothing wrote down.

The lost update

The specific failure above has a name — the lost update — and it comes from a pattern that is everywhere: read a value, compute something from it, write the result back. Read-modify-write. Between the read and the write there is a window, and for however long that window is open, the value you are holding is a fact about the past.

If nobody else touches the state during the window, the code is correct. If somebody does, your write is based on a number that is no longer true, and it lands on top of their work as if it had never happened. Nothing throws, and there is no corrupted row, no stack trace, no log line — only a counter that is quietly a little too low, an inventory that says two left when there is one, or a balance a customer will notice before you do.

The scene below runs two workers against one counter twice. The first pass is the code as written; the second rewinds to the same starting value and runs the identical increments behind a lock.

Predict first

In both passes views starts at 5, and A and B each add one. Unlocked, both reads land before either write; behind the lock, B cannot read until A has written. What does views hold at the end of each pass?

  1. Each worker does its arithmetic correctly, but B adds one to the 5 it read, not to the 6 that A wrote. Correct arithmetic on a stale value still writes the wrong number.

  2. Unlocked, both read 5 and both write 6, so one sale vanishes. Behind the lock, B reads the 6 that A wrote and stores 7. The badges keep the tally: of the four writes, three counted a sale and one counted nothing.

  3. The lock does not change what either worker computes; it changes what B reads. Because B cannot read until A has written, it reads 6 and writes 7.

  4. That assumes clashing writes cancel out or get rejected. Nothing detects the clash: the second write lands like any other and stores 6, which is exactly why a lost update is so hard to notice.

Show the answer

The answer is B. The reasoning for each option is now shown under it.

Two increments, one counter

a read or a write by A or B · teal = kept, rust = lost · badges tally writes

UNLOCKEDBEHIND A LOCK
A
B
views
lost
counted
A
B
views

Two workers, one counter: as written, then behind a lock — use Next step to go one at a time.

Follow the first five steps and the bug stops being subtle: both reads land before either write, so both workers compute 6 from the same 5. The two badges sort the scene’s four writes by what each one did to the total — three counted a sale, one counted nothing, because it stored a number a stale read had already produced. They tally writes rather than showing the value of views, so they only climb. In the locked pass that interleaving cannot happen: B is unable to read until A releases, so it reads 6, and the counter reaches the 7 that two sales are supposed to produce.

Why they are so hard to catch

A race condition is not a rare kind of bug so much as a rare kind of timing.

The window is usually microseconds wide, so two operations have to land inside it to collide at all. At ten requests a minute you may never see it; at ten thousand it happens constantly.

Some rough arithmetic shows how steep that curve is. Say the window is 100 microseconds. At ten requests a minute, the chance that another request lands inside any one window is about 1 in 60,000, which comes to one lost update every four days or so. At ten thousand a minute, about 167 a second, the chance is 1 in 60, and you lose around 167 updates every minute. A thousand times the traffic brings a million times the losses, because each request is a thousand times likelier to be hit and there are a thousand times as many of them. The bug arrives with success, on the day you can least afford it.

It also disappears when you look at it. A debugger, a breakpoint, or a log line between the read and the write changes the timing of everything involved, and the interleaving you were hunting stops happening. That is common enough to have a name — a Heisenbug — and it is why these are found by reasoning about the code rather than by reproducing them.

Tests are no help by default: a test calls your function once and sees the correct answer. Catching this needs a test that runs both operations concurrently on purpose, which you only write once you already suspect the problem.

Here is the whole bug in one function, in a language with exactly one thread:

interface Counter {
  read(): Promise<number>;
  write(value: number): Promise<void>;
}

async function increment(counter: Counter): Promise<void> {
  const current = await counter.read();
  // The function suspends at that await. Whatever ran while it was away is
  // invisible here: `current` is a number from before the gap.
  await counter.write(current + 1);
}

async function twoSalesAtOnce(): Promise<number> {
  let views = 5;
  const counter: Counter = {
    read: async () => views,
    write: async (value) => {
      views = value;
    },
  };

  await Promise.all([increment(counter), increment(counter)]);
  return views; // 6, not 7: both reads returned 5, so both writes stored 6
}

JavaScript runs one statement at a time, and a run of statements cannot be interrupted partway through. But await ends a run: the function hands control back to the event loop and resumes later as a microtask, and everything that happened in between is invisible to it. That gap is the window, and it is open even here, where read resolves immediately and nothing goes over a network — await always suspends, even when the value is already there.

One honest note about that snippet: it loses the update every single time, which makes it a teaching example rather than a real race. Put a database behind Counter and the same code loses the update only sometimes, which is what makes the real thing so unpleasant.

Try it

Put the increment behind a lock

Two sales make a small race; a hundred make a loud one. Start 100 calls to increment at once on a counter at 5, the way twoSalesAtOnce starts two, and print the result, which should be 105. Then suppose Counter is a store you cannot change, so the read and the write have to stay in your code, and write a lockedIncrement that admits one increment at a time, so that all 100 count.

Hint

JavaScript has no built-in mutex, and does not need one. Keep a promise for the last job in line, and chain each new job onto it with .then, so that it starts only once the job before it has finished. Then ask what happens to the line if one job throws.

Solution
let tail: Promise<unknown> = Promise.resolve();

function withLock<T>(work: () => Promise<T>): Promise<T> {
  const result = tail.then(work);
  tail = result.catch(() => undefined);
  return result;
}

async function lockedIncrement(counter: Counter): Promise<void> {
  await withLock(async () => {
    const current = await counter.read();
    await counter.write(current + 1);
  });
}

withLock reads tail and replaces it in one synchronous run, with no await in between, so the lock has no window of its own: two callers can never both see the same tail. Each increment’s read therefore waits for the previous increment’s write, which is the lower lane of the scene. The catch matters as much as the chain: without it, one failed write would leave tail rejected, and every increment after it would fail without running.

One tail for the whole module serializes every counter; a real service keeps one per key. And if you can change Counter, there is a better fix than any lock: an add(delta) that the store performs in one step has no window to guard, which is where the last section of this page begins.

To see it work, run both versions on the same 100 sales:

async function salesAtOnce(
  sales: number,
  inc: (counter: Counter) => Promise<void>,
): Promise<number> {
  let views = 5;
  const counter: Counter = {
    read: async () => views,
    write: async (value) => {
      views = value;
    },
  };
  await Promise.all(Array.from({ length: sales }, () => inc(counter)));
  return views;
}

console.log(await salesAtOnce(100, increment)); // 6: 99 sales lost
console.log(await salesAtOnce(100, lockedIncrement)); // 105: all 100 counted

Threads, and the network beyond them

The single-threaded version is the friendly case, because the suspension points are visible — every one of them is spelled await. With real threads, the interleaving can happen between machine instructions. count++ is not one operation; it is a load, an add, and a store, and another thread can be scheduled between any two of them. There is no keyword marking the window because the window is everywhere.

Shared memory is not required either. Two processes on two machines with no memory in common still race, because the shared state is the database row, the S3 object, or the file on disk. Check-then-act is the usual shape: two requests both check whether a username is taken, both find it free, and both insert it. The same applies to any two writes to one row, which is why a transaction alone does not save you — at the isolation levels most databases use by default, two transactions can both read 5 and both write 6 without either one being invalid.

Closing the window

There are four families of fix, in the order to reach for them.

Stop doing read-modify-write in your code. Hand the whole operation to whatever owns the state, so it becomes one indivisible step with no window to exploit: UPDATE pages SET views = views + 1 WHERE id = $1 in SQL, INCR in Redis, Atomics.add over a SharedArrayBuffer. It is the cheapest fix and the most often available, because most lost updates are arithmetic the store could have done itself.

When the work genuinely has to happen in your process, use mutual exclusion — a lock that admits one worker at a time to the stretch of code touching the shared state, which is the second pass in the scene above. It is general and it always works, and it buys you contention, reduced throughput, and a new failure mode — a deadlock — where two workers wait on each other forever.

Optimistic concurrency inverts the bet: rather than prevent the conflict, make the write conditional on what you read — UPDATE ... SET views = 6 WHERE views = 5, or a version column checked on every write — and retry when it matches no rows. Excellent when conflicts are rare, expensive when they are not, and only safe if the work is written to be repeated.

The last option is to stop sharing: give each worker its own counter and sum them when somebody asks. No shared state, no window, no lock — at the cost of a slower read and an answer that is a moment behind.

Check yourself

Pick an answer to lock it in. You then get the reasoning behind every option, including the ones you didn't pick. Any you miss come back on the review page a day later.

A Node.js checkout handler runs const stock = await db.getStock(id); if (stock > 0) await db.setStock(id, stock - 1); in one process on a one-core server. One unit is left. Two orders arrive together, both succeed, and stock ends at 0. How?

  1. Node runs all of your JavaScript on one thread, and it does not need a second one to race. Each await hands that thread to the other request, which is enough to interleave them.

  2. A race needs interleaving, not parallelism. One core switches between the two requests at every await, so both can read before either writes.

  3. Order does not matter here: both writes store 0, so either order ends the same. The damage was done earlier, when both handlers read 1.

  4. Each await lets the other handler run. Both read 1, both pass the stock > 0 check, and both write 0: two sales, one decrement. It is check-then-act, and the same lost update as the scene.

Show the answer

The answer is D. The reasoning for each option is now shown under it.

A page counter runs SELECT views FROM pages WHERE id = $1, adds one in TypeScript, then runs UPDATE pages SET views = $2 WHERE id = $1. At peak traffic it undercounts. Which change fixes it?

  1. At the default isolation level of most databases, two transactions can still both read 5 and both write 6, and each is valid on its own. A transaction makes one request all-or-nothing; it does not stop two of them reading the same value.

  2. A delay moves the collisions around rather than removing them. Two requests can still both read before either writes, and at peak traffic they will; the bug just becomes rarer and harder to reproduce.

  3. The database now reads and writes the row as one step, under its own row lock, so no second request can read the old value in between. It removes the window instead of guarding it, which is why it is the first fix to reach for.

  4. Nothing reports an error. Both UPDATEs succeed and each changes one row; a lost update throws nothing and leaves no trace for a retry to react to.

Show the answer

The answer is C. The reasoning for each option is now shown under it.

A unit test awaits increment(counter) once on a counter at 5 and asserts 6. It has passed for months while production loses views. Which test would have caught the bug?

  1. await Promise.all([increment(counter), increment(counter)]) overlaps the two read-modify-writes on purpose. Against the in-memory counter on this page it fails every time, with 6. A race only shows up when operations overlap, so the test has to make them overlap.

  2. Each repetition finishes before the next one starts, so no two increments ever overlap, and it passes a thousand times. Running a sequential test more often does not make it concurrent.

  3. Stepping through one call shows one call; the bug needs a second one landing inside its window. A debugger also changes the timing it is meant to observe, which is how a race becomes a Heisenbug.

  4. Cores are not the missing ingredient. This code races on a single thread; what the test lacks is a second increment running at the same time, and no machine will start one for it.

Show the answer

The answer is A. The reasoning for each option is now shown under it.

Sign-up runs SELECT 1 FROM users WHERE username = $1 and inserts the user if no row comes back. Two people choose ada at the same moment, and their requests land on two different app servers. What prevents a duplicate account?

  1. A mutex lives in one process. The other server has its own, so the two requests never wait for each other. A lock only serializes work that shares it.

  2. The database, which owns the state, checks and inserts as one step, so the second insert fails however the requests interleave. Catch that error and tell the person the name is taken.

  3. The shared state is the users table, not memory. Both requests can check, both find no ada, and both insert. Two machines race over a database row as easily as two threads over a variable.

Show the answer

The answer is B. The reasoning for each option is now shown under it.

Key takeaways

  • A race condition is a correctness bug whose outcome depends on timing your code never specified, so “it works” and “it is correct” stop being the same claim.

  • The classic shape is read-modify-write: between the read and the write, the value you are holding is a fact about the past.

  • Single-threaded async code races too — every await is a suspension point where other work can run and change what you read.

  • Prefer making the operation atomic in whatever owns the state; reach for a lock only when the work genuinely has to happen in your process.