What a race condition is
A race condition is a bug where the result depends on the timing of operations that overlap. The most common form is a lost update: two requests read the same value, each computes a new one, and the second write silently overwrites the first.
Races are hard to catch because the bad timing is rare. Code passes every test that runs one request at a time, then fails under load or when a user double-clicks a button.
Another frequent form is check-then-act. Two signups both check that a username is free, both see that it is, and both insert it.
Concurrency vs parallelism
Concurrency means several tasks are in progress during the same period, with their steps interleaved. Parallelism means steps run at the same instant on different CPU cores. A program can be concurrent on a single core, and a race needs only concurrency.
Node.js shows why. Its JavaScript runs on one thread, so two lines never execute at the same instant. Every await, though, is a point where another request can run. A read before an await and a write after it can interleave with another request doing the same.
Running more than one server instance adds true parallelism. In-process locks stop helping, because each instance has its own memory, and the shared database becomes the place where coordination has to happen.
A lost update, step by step
Take an account with a balance of 100. Two withdrawals arrive at once, one for 20 and one for 30. The code reads the balance, subtracts in the application and writes the result back.
The final balance is 70 when it should be 50. Request A's withdrawal disappeared without any error, because B computed its result from a value that was already stale. It happens in this order:
- Request A reads the balance: 100.
- Request B reads the balance: 100.
- Request A writes 100 minus 20, so the balance is 80.
- Request B writes 100 minus 30, so the balance is 70.
Fixes: atomic updates, constraints and locks
The simplest fix is to let the database read and write in one atomic statement. The atomic UPDATE in the example below subtracts and checks the balance in a single step. Concurrent withdrawals then wait for the row lock instead of overwriting each other. If it changes zero rows, the funds were insufficient.
For check-then-act races, a unique constraint is the fix. Let both inserts try, and handle the duplicate-key error for the one that loses.
When the logic is too complex for one statement, lock the row with SELECT ... FOR UPDATE inside a transaction. Other transactions that try to update or lock the same row wait until you commit, while plain reads still see the last committed value. Keep these transactions short, and lock rows in a consistent order, such as by ID, to avoid deadlocks.
Inside one process, a mutex or your language's atomic types do the same job for in-memory state. They don't coordinate separate servers.
-- Race-prone: read in the application, compute, write back
SELECT balance FROM accounts WHERE id = 42;
UPDATE accounts SET balance = 70 WHERE id = 42;
-- Atomic: the database reads and writes in one statement
UPDATE accounts
SET balance = balance - 30
WHERE id = 42 AND balance >= 30;
-- Pessimistic lock: hold the row until COMMIT
BEGIN;
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
UPDATE accounts SET balance = balance - 30 WHERE id = 42;
COMMIT;Transactions and isolation levels
Wrapping read-modify-write code in a transaction doesn't prevent lost updates by itself. At the default isolation level of PostgreSQL (read committed) and MySQL InnoDB (repeatable read), the race-prone code above can still lose an update.
Stricter levels detect the conflict instead. In PostgreSQL, repeatable read and serializable abort one of the conflicting transactions with a serialization failure (SQLSTATE 40001). Your code must catch that error and retry the whole transaction, so the transaction body has to be safe to run twice.
Serializable isolation is the easiest to reason about, because the outcome matches some one-at-a-time order. It also causes more retries under contention. Many systems use read committed plus atomic updates or explicit locks on the rows that matter.
Optimistic concurrency and idempotency keys
Optimistic concurrency assumes conflicts are rare and checks for them at write time. Each row carries a version number, and an update includes WHERE version = 7 while setting version = 8. If it changes zero rows, another writer got there first, so you reload and retry or show the user a conflict.
HTTP has the same pattern built in. A client sends the ETag it last saw in an If-Match header, and the server answers 412 Precondition Failed if the resource has changed since.
Idempotency keys handle a different race: the same request arriving twice. A client that times out on a payment and retries could charge the customer twice. With an idempotency key, the client sends a unique ID with the request, often in an Idempotency-Key header, and the server stores it with the result.
A retry with the same key returns the stored result instead of repeating the work. Store keys under a unique constraint, because two copies of a request can also arrive at the same moment.