Let’s talk

Interview trap 01

The double-booked seat

Two users hit 'Book' on the last seat at the same moment. Your code checks availability first. Who gets the seat?

Recorded from a real run against Postgres. Timings are real.

Alice and Bob hit the button at the same moment.

Press play, or step with ← → . Click any event on the timeline to jump to it.

AliceBobPostgresPayment APIrace window: requests overlap here, so each one decides on stale data5ms88ms5ms · alice: POST /book { seat: "A1" }5ms · bob: POST /book { seat: "A1" }6ms · alice: SELECT seats → status = 'available'7ms · alice: charging alice… (80ms)7ms · bob: SELECT seats → status = 'available'7ms · bob: charging bob… (80ms)88ms · alice: UPDATE seats → 1 row changed88ms · bob: UPDATE seats → 1 row changed91ms · alice: INSERT bookings → 1 row changed91ms · bob: INSERT bookings → 1 row changed✓92ms · alice: CONFIRMED!92ms · bob: CONFIRMED
readwriteslow work (overlap = race window)response to user
bug.ts
1import type { Ctx } from "../shared/ctx.ts";
2
3/**
4 * BUG: check-then-act.
5 * The SELECT and the UPDATE are two separate moments in time.
6 * Anyone who reads in between sees "available" too.
7 */
8export async function bookSeat(ctx: Ctx, seatId: string, userId: string) {
9 // step: read
10 const { rows } = await ctx.db.query(
11 "SELECT status FROM seats WHERE id = $1",
12 [seatId],
13 );
14
15 // step: check
16 if (rows[0].status !== "available") {
17 return ctx.respond("SOLD_OUT");
18 }
19
20 // step: pay
21 await ctx.chargePayment(userId); // the gap where everyone else sneaks in
22
23 // step: write
24 await ctx.db.query(
25 "UPDATE seats SET status = 'booked', booked_by = $2 WHERE id = $1",
26 [seatId, userId],
27 );
28 await ctx.db.query(
29 "INSERT INTO bookings (seat_id, user_id) VALUES ($1, $2)",
30 [seatId, userId],
31 );
32
33 // step: respond
34 return ctx.respond("CONFIRMED");
35}
36

What users were told

Alicewaiting…Bobwaiting…

seats · 1 row

idstatusbooked_by
A1availableNULL

bookings · 0 rows

(empty)

Why it breaks

Both requests read 'available' before either one writes. The check and the write are separate statements, so the gap between them is a window anyone can walk through.

The interview answer

Never check-then-act across two statements. Make the check part of the write (UPDATE … WHERE status = 'available'), and back it with a UNIQUE constraint.

Make it fail on your machine

The proof fires 50 concurrent requests at a real Postgres. One command.

npm run 01:bug