Part 01 · The Backend Mindset

Two Users, One Action

RaceConditionConcurrency

What happens when two people buy the last ticket at the same moment, and why it is hard.

⏱ 1-2 hours · Station 6 of 153

One seat. Five tickets.

A concert has one seat left. Five people press Buy within the same second. Your code is careful: it checks that a seat is available before selling it.

All five get a confirmation email. All five show up on the night.

Nobody's code crashed. Every request did exactly what it was written to do. And yet the system sold five tickets for one seat. This lesson is about how that happens, why it's so hard to see in testing, and the small number of ways engineers stop it.

The problem: checking and acting are two steps

The buy handler looks perfectly reasonable:

  1. Read how many seats are left.
  2. Check that it's at least 1.
  3. Write the new count, and issue a ticket.

With one user at a time, this is correct. With many users at the same time (lesson 1), the steps of different requests can interleave. Request A reads, then request B reads, and both see the same "1 seat left" before either of them writes.

MomentAlice's requestBob's requestSeats in storage
t1reads: 11
t2reads: 11
t3checks 1 ≥ 1: OK1
t4checks 1 ≥ 1: OK1
t5writes 0, ticket!0
t6writes 0, ticket!0

Two tickets were sold, and the count says 0, which looks completely normal. This is a race condition: the result depends on the timing of things that run at the same time. This particular shape, read, then decide, then write, is called check-then-act, and it's the most common race in backend code.

Think of two people and one whiteboard

A shared flat has a whiteboard: "Milk: 1 bottle". Two flatmates walk past it at the same moment, both read "1", and both think: "there's milk, I'll take it for my tea". Both go to the fridge. One gets milk; the other finds an empty shelf, and the board says 0, so nobody notices anything wrong.

The board wasn't wrong when they read it. It was just out of date by the time they acted on it. The fix isn't to read more carefully; it's to make "read the board and take the milk" something only one person can do at a time, or to write your name on the bottle before you walk to the fridge.

read, check, write: the gap in the middleAliceBobseatsstarts at 1ticketticketread 1read 1both saw 1 seatboth wrote 0: two tickets, one seatwith a lock: Bob waits, reads 0, refusedread 0
Alice and Bob both read "1 seat", both write 0, and both get a ticket. Then the same two with a lock: Bob has to wait until Alice is finished, reads 0, and is refused.

Where the analogy stops working. Flatmates take seconds; requests overlap within microseconds, thousands of times a second. And people at least sometimes notice each other. Requests never do: each one runs as if it were alone, and nothing about the code tells you another one is in the middle of the same lines.

The building blocks

  1. Concurrency. Two or more pieces of work in progress at the same time, on threads, goroutines, async tasks, or separate servers. Lesson 1 showed why backends need it; this lesson shows its cost.
  2. Shared state. Something more than one request can read and write: a seat count, a balance, a stock level, a username. No shared state, no race.
  3. Race condition. The outcome depends on timing. The same code with the same inputs is right most of the time and wrong some of the time.
  4. Critical section. The lines that must not interleave: here, read the count, check it, write it. The fix is always to make the critical section behave as one indivisible step (atomic). There are three common ways:
    • A lock (mutual exclusion). Only one request may be inside the critical section at a time; others wait. Simple, but it only works inside one process, and waiting costs time.
    • An atomic conditional write. Let the storage do check and write together: UPDATE seats SET left = left - 1 WHERE id = 7 AND left >= 1, then see how many rows changed. 1 means you got it, 0 means sold out. This works across many servers.
    • A uniqueness rule. Let the database refuse duplicates: a unique constraint on (concert, seat) or on username. Two inserts race; one wins, the other gets an error.
  5. Why testing misses it. On your laptop, one user clicks at a time and the gap between read and write is microseconds. In production, thousands of requests and a database round trip of several milliseconds make the gap wide enough to hit, every day.
Predict first

Node.js runs JavaScript on a single thread. A handler does: let left = await getSeats(); if (left >= 1) await setSeats(left - 1). Can two requests still sell the same seat?

Behind the scenes: two requests, one counter

The same two buyers, first without protection, then with a lock.

Step through it

Check-then-act, with and without a lock

AliceBobSeat countLock

Step 1/6 Alice reads the count

Alice's request asks storage how many seats are left. The answer is 1. Then it waits for its next step, as real code does while it talks to a database.

Alice saw
1
seats
1

The picture to keep in your head

Draw every request as a horizontal line of steps, and stack the lines on top of each other, all on one time axis. Wherever two lines have a read and a write of the same thing, and those can slide past each other, there's a possible race.

Then ask one question of any code that reads something and writes it back:

What if someone else changes this between my read and my write?

If the answer is "a wrong result", the read-check-write has to become a single step: a lock, an atomic conditional update, or a unique constraint.

Try it: race or not?

Try each case

Is there a race here?

likes = getLikes(post)
setLikes(post, likes + 1)
breaks: A race. Two likes at once both read 10 and both write 11; one like is lost. Use an atomic increment (UPDATE posts SET likes = likes + 1) or count rows in a likes table.

Code from first principles

One seat, five buyers who all press Buy at the same moment. A 50 ms pause between reading and writing stands in for a database round trip.

  • check, then buy: read, pause, check, write, as separate steps.
  • check and buy as one step: the same logic inside a lock, so only one buyer can be in the critical section at a time.

In Node.js there's one thread, so the "pause" is an await; that's enough to create the same race.

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

const buyers = 5

// The gap between reading the seat count and writing it back, like a database round trip.
func gap() { time.Sleep(50 * time.Millisecond) }

// run lets every buyer try at the same moment and counts the results.
func run(buy func() bool) (sold, refused int) {
	var mu sync.Mutex
	var wg sync.WaitGroup
	for range buyers {
		wg.Add(1)
		go func() {
			defer wg.Done()
			ok := buy()
			mu.Lock()
			if ok {
				sold++
			} else {
				refused++
			}
			mu.Unlock()
		}()
	}
	wg.Wait()
	return sold, refused
}

func main() {
	fmt.Printf("1 seat left, %d people press Buy at the same moment\n", buyers)

	// Check, then buy: two separate steps with a gap between them.
	var seats atomic.Int64
	seats.Store(1)
	sold, refused := run(func() bool {
		left := seats.Load() // read
		gap()
		if left < 1 { // check
			return false
		}
		seats.Store(left - 1) // write
		return true
	})
	fmt.Printf("%-28s sold %d, refused %d, seats left %d\n", "check, then buy:", sold, refused, seats.Load())

	// Check and buy as one step: a lock lets only one buyer in at a time.
	var lock sync.Mutex
	left := 1
	sold, refused = run(func() bool {
		lock.Lock()
		defer lock.Unlock()
		gap()
		if left < 1 {
			return false
		}
		left--
		return true
	})
	fmt.Printf("%-28s sold %d, refused %d, seats left %d\n", "check and buy as one step:", sold, refused, left)
}

Output (the same in all five languages):

1 seat left, 5 people press Buy at the same moment
check, then buy:             sold 5, refused 0, seats left 0
check and buy as one step:   sold 1, refused 4, seats left 0

Reading the output:

  • check, then buy: sold 5, seats left 0. All five read "1", all five passed the check, all five wrote 0. The seat counter looks perfectly healthy, which is why oversells are usually found by angry customers, not by monitoring.
  • check and buy as one step: sold 1, refused 4. The lock turned read-check-write into one step. The second buyer in line read 0 and was refused.
  • The cost: the locked version takes about 5 × 50 ms, because buyers wait in line. Correctness is paid for in waiting. That's why real systems keep critical sections as short as possible, ideally a single database statement.
  • Node.js included. No threads, same race. The gap isn't about threads; it's about anything that can run between your read and your write.

Real engineering

  • Push the decision into the database. Prefer UPDATE ... WHERE stock >= 1 and checking rows affected, or INSERT with a unique constraint, over read-in-code-then-write. The database sees every request from every server.
  • Transactions help, but read the fine print. A transaction groups statements, but under the default isolation level of many databases, two transactions can still both read the same value and both write. SELECT ... FOR UPDATE (a row lock) or a stricter isolation level is needed for check-then-act. (Module 6, lessons 11–13.)
  • Optimistic concurrency. Keep a version column. Write with UPDATE ... SET qty = 3, version = 8 WHERE id = 1 AND version = 7. If nobody else wrote in between, it succeeds; if 0 rows change, someone did, so reload and retry. Great when conflicts are rare.
  • Idempotency is a cousin. One user pressing Buy twice (or a retry after a timeout) is the same problem from one person. Idempotency keys make the second attempt a no-op. (Module 3 lesson 13, Module 7 lesson 10.)
  • Test for it on purpose. Fire many concurrent requests at the same resource in a test, and run Go's -race detector or Java's stress tools. Races that need luck to appear will appear in production.

When it breaks

Try each case

Pick a failure

stock = 0, orders for that item today: 37 (stock was 30)
breaks: Check-then-act on stock. Diagnose by comparing orders with stock movements around busy moments. Fix with a conditional decrement (WHERE stock >= qty) or row locks.

Think before you read on

You add a lock around the buy code, and in testing it works perfectly. Then you deploy two copies of the server behind a load balancer. Does the oversell come back?

Reveal

Yes. Each copy has its own lock in its own memory (lesson 5). A buyer on copy A and a buyer on copy B never wait for each other, so they can both read 1 and both sell. In-process locks protect one process. Across processes, let the database decide (a conditional update, a row lock, a unique constraint), or use a lock that lives in a shared place (Module 8).

Check yourself

1. What is a race condition, in one sentence?

Answer

A bug where the result depends on the timing of operations that run at the same time, so the same code is sometimes right and sometimes wrong.

2. Why does the unsafe version end with "seats left 0", which looks correct?

Answer

Every buyer wrote "1 − 1 = 0" based on the same stale read. The writes overwrite each other with the same value, so the count looks fine while five tickets were issued.

3. Name three ways to make check-then-act safe.

Answer

A lock around the critical section; an atomic conditional write (UPDATE ... WHERE left >= 1, then check rows affected); a unique constraint so the database refuses duplicates. (Optimistic concurrency with a version column is a fourth.)

4. Why isn't a mutex in your Go or Java code enough when you run several servers?

Answer

The mutex lives in one process's memory. Requests on other copies don't see it, so they can still interleave. The shared decision has to be made somewhere every copy shares, usually the database.

Your challenge: no lock allowed

Write a third version of the buyer without a lock, using a compare-and-swap: an atomic operation that says "set seats to left − 1 only if it's still left". Go has atomic.Int64.CompareAndSwap, Java AtomicInteger.compareAndSet, Rust AtomicI64::compare_exchange; in Python and Node.js, simulate it with a tiny function that does the comparison and the write with no pause in between.

If the swap fails, someone else got in first: read again and retry.

You've succeeded when the new line prints sold 1, refused 4, seats left 0, it finishes much faster than the locked version (no buyer waits for another's whole critical section), and you can explain why it's faster than the lock and when retrying could become a problem.

The mental model

Many users means many requests running at once, and their steps interleave. Any time code reads shared state, decides, and writes it back, someone can change it in the gap: that's a race. Make the read-check-write one indivisible step: a lock inside one process, and an atomic conditional write or a unique constraint in the database when there's more than one.

Station stamp

Finish the chapter

Done reading? Press Complete to save your progress. XP is Pro-only — on Pro this chapter is worth +10 XP.

Go Pro to earn XP →