Learn / Fundamentals, the production version

Concurrency, for real

Lesson 2 of 37 · 9 min read ·

What it is

Concurrency is more than one thing being in progress at the same time. Parallelism is more than one thing executing at the same instant. Your Node service is concurrent without being parallel; a Java thread pool is both. The bugs are the same either way: two pieces of work touching the same state with no agreement about who goes first.

Why it bites

The canonical production bug is read-modify-write:

const account = await db.getBalance(id)   // reads 100
await db.setBalance(id, account - 30)     // writes 70

Two requests run this at once. Both read 100. Both write 70. One withdrawal vanished. Your tests never caught it because your tests run one request at a time.

A lost update: both transactions read before either wrote.A lost update: both transactions read before either wrote.

The same shape produces double-charged payments, duplicate user records that pass a "check if exists first" guard, and inventory that goes negative during a flash sale.

The mental model

Do not read then write. Make the database do both.

UPDATE accounts SET balance = balance - 30
WHERE id = $1 AND balance >= 30;

One statement, atomic, and the WHERE clause enforces the rule. If it affects zero rows, the withdrawal was invalid.

When the logic is too complex for one statement, pick a locking strategy:

  • PessimisticSELECT ... FOR UPDATE takes a row lock for the rest of the transaction. Other writers wait. Correct, but holds a lock, so keep the transaction short and never make an HTTP call inside one.
  • Optimistic — add a version column, and write WHERE id = $1 AND version = $2. Zero rows affected means someone beat you; retry. Cheaper under low contention, better for long user-facing edits.

Deadlock is two transactions holding what the other wants. The practical defence is boring: always acquire locks in the same order (for example, always the lower account id first), and keep transactions short.

Event loop vs threads

Node runs your JavaScript on one thread, so no two lines of your code interleave — but await is a yield point. Between two awaits, another request absolutely can run. That is enough for the bug above. Single-threaded does not mean race-free; it means races only happen at await.

With real threads (JVM, Go, Python with threads) any instruction boundary is a yield point, so you need locks or immutable data even for in-memory state.

Gotchas

  • In-memory locks do not work once you run two instances. Only a shared lock (database row, Redis) works across processes.
  • Promise.all starts everything at once — 500 items means 500 simultaneous connections. Batch it.
  • A transaction left open while awaiting an external API holds its locks for the whole call.
  • Retry loops without backoff turn contention into a stampede. See timeouts and retries.

Prove you know it

Write an endpoint that decrements stock, then fire 50 concurrent requests at it with 10 items in stock. If stock goes negative, you have reproduced the bug that every e-commerce team has shipped at least once. Fix it with a conditional UPDATE and run it again.

Go deeper