Data & consistencyIntermediate

Ticket booking, where two people want the same seat

The whole design is one guarantee — a seat is sold at most once — held under the worst traffic the system will ever see, in the first minute of an on-sale.

The brief

Users browse an event's seat map, pick seats, hold them while they pay, and receive tickets.

Popular events go on sale at an announced time, so demand arrives all at once rather than spread across the day.

Requirements

Functional

  • Show which seats are available for an event
  • Hold selected seats for one user for a limited time while they pay
  • Confirm the booking when payment succeeds; release the hold when it fails or expires
  • Never sell the same seat twice

Non-functional

  • A double-sold seat is a correctness failure with a person standing at a door; no amount of speed excuses it
  • The seat map may be a few seconds stale; the hold must not be
  • Survive an on-sale spike without the database becoming the queue

Back-of-envelope

Assume

  • A large venue: 50,000 seats
  • An on-sale that attracts 500,000 people in the first ten minutes
  • A hold lasts 10 minutes; checkout takes a user about 3 minutes
  • Each person refreshes the seat map every few seconds while waiting

Therefore

  • Demand is ten times supply, so at least 90% of the people who arrive will not get a seat. Most of the traffic is people who will be told no — the design has to make telling them cheap.
  • 500,000 people refreshing every 5 seconds is ≈100,000 seat-map reads a second. The seat-map read, not the booking, is the load.
  • Bookings are bounded by the venue: at most 50,000 successful holds in total, however many people try. The write path is small; it only has to be correct.
  • With a 10-minute hold and a 3-minute checkout, abandoned holds keep seats off sale for up to 10 minutes each. That number, not the database, is what decides how fast the event sells out.

Change the hold duration and watch the last line move: a 5-minute hold returns abandoned seats twice as fast and fails more slow payers. That trade-off is a product decision the estimate makes visible.

The interface

GET /events/{id}/seats → [{ seatId, state }] (cacheable for a few seconds)State is only AVAILABLE or TAKEN from the reader's point of view. Whether a taken seat is held or sold is none of their business, and collapsing the two is what lets this response be cached.
POST /events/{id}/holds { seatIds } → 201 { holdId, expiresAt } | 409 { unavailable: [seatId] }All-or-nothing across the requested seats: a family of four does not want three seats. The 409 names which seats were lost, so the client can update the map without another read.
POST /holds/{holdId}/confirm { paymentId } (Idempotency-Key) → 200 { tickets } | 410 hold expiredIdempotent because the payment callback and the user's retry will both arrive. 410 rather than 409, because the hold is gone rather than contested — and the client must handle it, because it will happen to someone who paid.

What is stored

seatsevent_id · seat_id · status (AVAILABLE | HELD | SOLD) · hold_id · held_until · PRIMARY KEY (event_id, seat_id)
The hold is a conditional UPDATE on this row — set HELD only WHERE status = AVAILABLE, or status = HELD AND held_until < now() — and the number of rows affected says who won. No read-then-write, so no race between them.
holdshold_id · user_id · event_id · expires_at · state · INDEX (state, expires_at)
The index serves exactly one query: the sweeper looking for expired holds to release. Without it the sweeper scans every hold ever made, and it runs every few seconds.
ticketsticket_id · event_id · seat_id · booking_id · UNIQUE (event_id, seat_id)
The unique constraint is the last line of defence. If every other part of the design has a bug, the second INSERT for the same seat fails in the database rather than at the venue door.

The design

The waiting roomIn front of the booking path during an on-sale: arrivals get a place in a queue and are admitted at a rate the booking path can serve. It turns 500,000 simultaneous attempts into a steady flow, and tells the people at the back their position instead of an error.
The seat-map cacheSeat availability served from a cache refreshed every second or two. It absorbs the 100,000 reads a second, and it is allowed to be wrong — a seat shown available that is not costs one 409, not a double sale.
The hold servicePerforms the conditional update in one transaction across the requested seats, ordered by seat id so two overlapping requests lock rows in the same order and cannot deadlock.
The expiry sweeperReleases holds past their deadline. The conditional update already treats an expired hold as available, so the sweeper is housekeeping for the seat map, not the thing that makes seats bookable again.

The decisions

Each of these could go the other way. The choice, the reason, and what it costs — a design that lists only what it chose teaches the choice; one that lists what it gave up teaches the judgement.

Lock the seat row pessimistically, or use a conditional update?ChoseA conditional UPDATE with the check in the WHERE clauseBecauseSELECT … FOR UPDATE followed by an UPDATE holds a row lock for a round trip and invites deadlocks between overlapping seat sets. A single conditional UPDATE is atomic on its own, and its row count is the answer.The loser learns only that the update touched fewer rows than expected, not why. For a multi-seat hold the transaction must roll back and report which seats failed, which is more code than a lock that simply waits.
Where does the hold live — the database or an in-memory store with a TTL?ChoseThe database, on the seat rowBecauseThe hold and the sale must agree, and they only agree for certain if they are in the same transaction as the ticket insert. An expiring key elsewhere can expire between the check and the sale.The database takes every hold write during the spike. That is exactly why the waiting room exists: it limits the arrival rate to what the database can hold, rather than moving the hold somewhere faster and weaker.
What happens when a payment succeeds after the hold expired?ChoseAttempt to re-acquire the same seats; refund if they are goneBecausePayment providers are slow at the tail, and a user who paid at 9:59 of a 10-minute hold will sometimes be confirmed at 10:04. Pretending that cannot happen produces a charge with no ticket and no refund.A refund path that must be as reliable as the payment path, and a small number of users who paid and are then told no. The alternative — honouring the late payment regardless — is how a seat is sold twice.

What breaks first

In order. Each names what you would actually observe, and each fix carries its cost.

Row contention on the popular sectionSymptomHold latency climbs and 409 rates spike for the front rows while the rest of the venue is quiet. Database CPU looks fine; lock wait time in the database's statistics is what moves.FixOffer 'best available' instead of letting everyone pick the same seats, so the system assigns from a pool rather than every request fighting over one row.Users lose the choice of an exact seat during the rush, which some events will not accept for their premium sections.
Seat-map reads hitting the databaseSymptomConnection pool saturation during the first minute, with booking writes queuing behind reads that did not need to be fresh.FixServe the map only from the cache, rebuilt from the database on a timer, never on demand.The map is always up to a couple of seconds old, so more users click a seat that has just gone and get a 409.
Abandoned holdsSymptomThe event shows sold out while real sales are far below capacity, then seats reappear in waves as holds expire — and users who refresh at the right moment get them.FixShorten the hold during the on-sale and extend it only when checkout has visibly progressed, for example when payment has started.Slow payers and users on poor connections lose holds they would have completed.

When something fails

The payment provider confirms, but the confirm call to this system never arrivesThe hold expires and the seat returns to sale while the user has been charged. Reconciliation against the provider's record of payments is what finds it; without that job, the only signal is an angry email.
The expiry sweeper stopsNo seat is double-sold, because the conditional update already treats expired holds as available. But the cached seat map keeps showing expired holds as taken, and the event looks more sold out than it is. Alert on the age of the oldest unreleased expired hold.
The waiting room fails open and admits everyoneThe database becomes the queue. Hold latency rises past the client timeout, clients retry, and retries multiply the load. Correctness holds — the unique constraint still refuses a second ticket — but almost nobody completes a booking.

Scaling it

Each step is triggered by a number, not a feeling — and carries what it costs.

Many events on sale at onceMovePartition by event: seats, holds and tickets for one event never interact with another's, so each event's rows can live on a different shard.A user's 'my tickets' view now spans shards and needs its own read model.
A single event larger than one database can hold writes forMoveSplit the venue into sections owned by different partitions, with the waiting room admitting per section.A hold that spans two sections becomes a distributed transaction, so the product must either forbid it or accept a more complicated saga.
10× the ordinary, non-on-sale trafficMoveNothing. Outside an on-sale the write rate is tiny and the cache serves the reads.None — this is the number that shows the design is sized for the spike, not for the average.

What gets probed

The design is the easy half. These are where the conversation goes, and each has a defensible answer above.

  • Two users click the same seat in the same millisecond. Walk through exactly which statement decides who gets it.
  • A payment succeeds eleven minutes after the hold was created. What does the user see, and is anyone's money at risk?
  • Why is the seat map allowed to be stale when the hold is not?
  • The event shows sold out, but only 70% of tickets are sold. What is happening?