I wrote the most normal checkout code there is. Read the stock, check it isn't zero, write stock minus one.
Then I sent 100 buy requests at it at the same time, against a real Postgres database. All 100 went through. The stock went from 100 to 99.
async function buy(id) { const { rows } = await db.query("SELECT stock FROM products WHERE id = $1", [id]); const stock = rows[0].stock; if (stock < 1) throw new Error("sold out"); await db.query("UPDATE products SET stock = $1 WHERE id = $2", [stock - 1, id]); } Nothing in there looks wrong. Here's what happens with just two buyers, Alice and Bob:
What went wrong
Both requests read the stock before either one wrote it.
- Alice reads 5.
- While her query is out at the database, Node runs Bob's request. Bob reads 5 too.
- Alice writes 5 - 1 = 4.
- Bob writes 5 - 1 = 4.
Two mugs sold, and the stock went down by one. Alice's write didn't fail. It just got overwritten. This is called a lost update.
"But JavaScript is single-threaded." It is, and it doesn't help. Every await is a point where Node goes and runs other code, including another call to the same function. The race isn't between threads. It's between your read and your write.
With 100 buyers, every request read 100, so every request wrote 99. And with 1 mug left and 2 buyers, both orders went through. The last mug got sold twice.
"Just wrap it in a transaction"
That was my first guess too. Same code, inside BEGIN and COMMIT (error handling left out):
await c.query("BEGIN"); const { rows } = await c.query("SELECT stock FROM products WHERE id = $1", [id]); const stock = rows[0].stock; if (stock < 1) throw new Error("sold out"); await c.query("UPDATE products SET stock = $1 WHERE id = $2", [stock - 1, id]); await c.query("COMMIT"); Still wrong. With 100 buyers the stock ended somewhere between 88 and 91, depending on the run.
The transaction did change one thing: Bob's UPDATE had to wait until Alice committed. But Bob was already holding the number 4, worked out from the 5 he read earlier. Waiting doesn't make him read again.
A plain SELECT doesn't lock anything in Postgres. With the default isolation level (READ COMMITTED), two transactions can read the same row at the same time, and that's the whole problem.
(Why around 90 and not 99? The connection pool had 10 connections, so the transactions ran roughly 10 at a time. Each group of 10 read the same number, so each group only counted as one sale. 100 minus about 10 groups is about 90.)
Fix 1: let the database do the math
Don't bring the number into JavaScript at all:
async function buy(id) { const { rowCount } = await db.query( "UPDATE products SET stock = stock - 1 WHERE id = $1 AND stock >= 1", [id]); if (rowCount === 0) throw new Error("sold out"); } stock = stock - 1 is worked out inside the database, on the current value, while it holds a lock on the row. Bob's update waits for Alice's, then sees 4, not 5.
The AND stock >= 1 does the sold-out check in the same step. If nothing's left, no row matches and rowCount is 0.
100 buyers: stock 0. 1 mug and 2 buyers: one order and one "sold out".
Fix 2: lock the row when you read it
Sometimes the logic doesn't fit in one UPDATE. Then keep the transaction and add FOR UPDATE to the read:
SELECT stock FROM products WHERE id = $1 FOR UPDATE Now Alice's read locks the row until she commits. Bob's SELECT waits, and when it finally comes back, it returns 4. Also stock 0 after 100 buyers.
The cost is that everyone buying that product waits in line. Fine for a mug. Not great for a row that every request touches.
There's a third way too: add a version column, and only write if the version is still the one you read. If someone got there first, your UPDATE matches 0 rows and you read again and retry. No waiting, just the occasional retry.
The numbers
All four versions against a local Postgres, 100 concurrent buys, starting stock 100, 5 runs each:
| Version | Orders | Stock left |
|---|---|---|
| Read, then write | 100 | 99 (every run) |
| Same, inside BEGIN/COMMIT | 100 | 88 to 91 |
| SELECT ... FOR UPDATE | 100 | 0 |
| One UPDATE | 100 | 0 |
The thing to remember
If your code reads a value, works something out, and writes it back, ask what happens when another request does the same thing between your read and your write. If the answer is "it gets overwritten", you have a lost update. Threads or no threads.
You can step through all five versions, including the version column one, here: https://software-atlas.haiduc0147.dev/t/race-conditions#lost-update



