Engineering Atlas › System Design Interviews

Stopping two people from booking the same slot

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

When the pattern shows up in interviews

Ticketing, hotel and flight booking, meeting-room calendars, ride matching and flash sales all hide one question: how is a scarce unit allocated exactly once? Interviewers often let you draw the happy path first and then ask, "What if two people click the same seat at the same millisecond?"

Option 1: Conditional write (optimistic)

Make the update conditional on the seat's state: UPDATE seats SET status='held', holder=$u, hold_expires=now()+'10 min', version=version+1 WHERE id=$s AND (status='available' OR hold_expires <= now()). One row affected means you won; zero means someone else did. No lock is held between requests. The same idea works with a version column or DynamoDB condition expressions.

Option 2: Unique constraint

Model the booking as an insert into a table with a uniqueness rule, such as UNIQUE(event_id, seat_id) on bookings. The second insert fails with a constraint violation. This is the most robust option because the invariant is declared rather than coded, so every code path, script and future service obeys it. For time ranges (hotel nights, meeting rooms), Postgres offers an exclusion constraint over a range type: EXCLUDE USING gist (room_id WITH =, during WITH &&) rejects any overlapping reservation for the same room. The room_id WITH = part needs the btree_gist extension, because plain scalar types have no GiST operator class without it (PostgreSQL docs). PostgreSQL 18 (September 2025) adds a friendlier spelling of the same rule, a temporal key: PRIMARY KEY (room_id, during WITHOUT OVERLAPS), enforced by a GiST index, and foreign keys can reference it with PERIOD (PostgreSQL 18 release notes, CREATE TABLE).

Option 3: Pessimistic lock

SELECT ... FOR UPDATE locks the row so the transaction can check rules too complex for one statement (price rules, multi-seat adjacency) before updating; competitors block. Never hold it across a payment call or user think time. For "give me any free seat in this section", FOR UPDATE SKIP LOCKED lets concurrent buyers grab different rows instead of queueing on the same one; the docs warn that skipping gives an inconsistent view, fine for "any row" but not for "is this exact seat free?" (PostgreSQL docs).

Do not rely on isolation level alone for a read-then-write check. Jepsen found that MySQL 8.0.34's default REPEATABLE READ allowed lost updates (198 in one test run) and concluded that such transactions cannot safely read a value and then write it; use SELECT ... FOR UPDATE, SERIALIZABLE, or a conditional write (Jepsen).

Holds and two-phase booking

Payment takes seconds and users abandon carts, so real systems split booking into hold and confirm. The hold is a conditional write with an expiry. Confirmation is another conditional write that succeeds only if the hold is still owned by this user and has not expired: UPDATE ... SET status='sold' WHERE id=$s AND holder=$u AND hold_id=$h AND hold_expires > now(). Expiry needs no background job for correctness, because the conditions treat an expired hold as available; a sweeper only tidies up. The payment is authorised during the hold and captured only after confirmation succeeds, and the payment call carries an idempotency key so retries do not charge twice.

Where Redis locks fit

Putting the hold in Redis with SET seat:123 user NX PX 600000 is fast, but a failover can lose a just-acknowledged key, and a client paused by GC can act after its lock expired. Redis's own docs now discourage this single-node lock pattern in favour of Redlock (Redis docs), and Redlock still assumes bounded clock drift and pauses and cannot issue increasing fencing tokens (Kleppmann). Treat Redis as a filter and keep the database condition as the arbiter, or pass a fencing token (increasing per grant) that storage checks.

Handling the stampede

At a big on-sale, even perfect atomic updates serialise on hot rows and most requests fail. Strong answers move contention out of the database: a virtual waiting room admits users at a controlled rate (Cloudflare's, for example, is configured as "total active users" plus "new users per minute", and admits the oldest arrival-minute buckets first (Cloudflare)), seat maps come from a slightly stale cache (the conditional write catches staleness), and inventory is partitioned by section.

Down the stack

Layer 1The contract

Invariant: for each (event, seat), at most one booking in state sold, and at most one live hold at any instant. A hold request returns success to exactly one of N concurrent callers. A confirm succeeds only for the current, unexpired hold. Failures must be safe: a crash at any point may leave a hold that expires, never a seat sold twice or a charge without a seat.

Layer 2The mechanism

In Postgres, an UPDATE finds the row version visible to its snapshot, takes a row-level lock by writing its transaction ID into the tuple's xmax, and creates a new tuple version. A concurrent UPDATE on the same row waits on the first transaction. Under READ COMMITTED, when the first commits, the waiter re-evaluates its WHERE clause against the new version (the "EvalPlanQual" recheck), so it sees status='held' and updates zero rows. Under REPEATABLE READ the waiter instead fails with "could not serialize access due to concurrent update" (SQLSTATE 40001) and must retry the whole transaction (PostgreSQL docs). A unique index is enforced inside the B-tree insert: the second inserter finds a conflicting entry, waits if the first transaction is still in flight, then raises unique_violation if it committed. An exclusion constraint does the same with a GiST index search for overlapping ranges.

Layer 3The machine

Row locks live in the tuple header on the heap page, not in a separate lock table, so locking millions of rows costs no shared memory; waiters block on the holder's transaction ID lock instead. The commit that makes a booking durable is one WAL fsync, and group commit lets many transactions share one flush, which is why a single Postgres primary can commit thousands of small booking transactions per second on fast storage. A hot row serialises: if each holder keeps the lock for 2 ms (one round trip plus commit), that row tops out around 500 updates per second no matter how many cores the server has. A BEGIN, SELECT FOR UPDATE, UPDATE, COMMIT sequence from an app server 0.5 ms away holds the lock for several round trips; one conditional UPDATE holds it far less.

Why it's built this way

  1. Two independent readers cannot both see each other's future writes, so the check must happen where writes are serialised: the row or index in the database.
  2. Holding locks across user think time or payment would block everyone else, so the flow splits into a short atomic hold and a later atomic confirm.
  3. Holders crash and abandon carts, so holds carry an expiry that the conditions themselves enforce, without relying on a cleanup job.
  4. Hot rows serialise on lock hold time, which is bounded by network round trips and commit latency, so high-demand sales add admission control before the database.

Diagram

stateDiagram-v2
    [*] --> Available
    Available --> Held: hold (conditional update wins)
    Held --> Available: expires or released
    Held --> Held: same user refreshes
    Held --> Sold: confirm with matching hold id before expiry
    Sold --> Available: refund or cancellation
    Sold --> [*]

Worked example

A stadium sale offers 50,000 seats and expects 400,000 users in the first minute. Without control, that is thousands of hold attempts per second concentrated on the best few thousand seats.

The team puts a waiting room in front: users get a queue position and are admitted at 2,000 per minute, roughly the rate at which held seats either convert or expire. Admitted users see a seat map cached for 2 seconds. Clicking a seat runs the single conditional UPDATE with a 7-minute hold. With 2,000 admitted users each making a few attempts, the database sees perhaps 100 to 200 hold writes per second spread across 50,000 rows, well within one primary. Most conflicts come from the cached map being slightly stale; they cost one failed update and a refresh.

Payment uses the hold ID as its idempotency key. If confirm then updates zero rows because the hold expired during a slow payment, the service voids the authorisation. Holds that expire unpaid return to the pool through the expiry condition alone.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Conditional updateOne round trip, no held locks, simpleRule must fit one statementSingle seat or unit, most cases
Unique or exclusion constraintInvariant declared once, enforced on every pathNeeds a modelling fit, errors to translateSlots, ranges, one-booking-per-key
SELECT FOR UPDATEArbitrary checks in a transactionLock wait queues, deadlock riskMulti-row rules, low contention
Redis lock or holdVery fast, offloads the DBNot safe alone under failover or pausesPre-filter in front of a DB check
Waiting room or queueSmooths spikes, fair orderingExtra latency and infrastructureFlash sales, ticket on-sales

Pitfalls in production

Leadership lens

Check yourself

  1. Two transactions run the conditional hold UPDATE on the same row under READ COMMITTED. Why does exactly one succeed?

    Show answer

    The second blocks on the first's row lock, then re-evaluates its WHERE clause against the committed new version, finds the seat held, and updates zero rows.

  2. Why is it safe for a hold to expire without any background job?

    Show answer

    Both hold and confirm conditions compare hold_expires with the current time, so an expired hold is treated as available and cannot be confirmed.

  3. A payment succeeds but the confirm updates zero rows. What happened and what should the system do?

    Show answer

    The hold expired and someone else may have taken the seat; void or refund the payment using its idempotency key and tell the user.

  4. Why can a hot row only take a few hundred updates per second even on a large server?

    Show answer

    Updates to one row are serialised by its lock, so throughput is bounded by how long each holder keeps the lock, which is set by round trips and commit latency.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →