Dev Learning

Ticket booking

Design a system for booking seats at events. Seats are specific and numbered. Popular events sell out in under a minute, with far more people trying than there are seats. Nobody may ever end up with a seat somebody else also has.

Where’s the slack?

Browsing: minutes. Holding a seat: minutes. The commit: zero.

Almost everything here has generous slack. One operation has none at all, and that operation is the problem.

Browsing has minutes of slack. The event listing, the seat map and the price can all be cached hard and served stale. They must be, because at on-sale time a hundred thousand people load that page in ten seconds. A seat map that is five seconds out of date is fine; someone clicks a seat that was just taken and is told so at the next step.

The hold has minutes of slack by design. You give the user 10 minutes to enter card details, and that window is itself a decision about how much inventory you are willing to make unavailable to other people.

The commit has zero slack and zero tolerance for being wrong. At the instant a seat is assigned, the decision must be exact, serialised, and durable. No eventual consistency, no cache, no approximation. If two commits succeed for one seat, you have sold something twice and no amount of speed makes up for it.

Everything after (the confirmation email, the ticket PDF, the analytics, the reconciliation with the venue) has minutes or hours of slack and belongs on a queue.

So: The slack profile splits the system cleanly in two: a massively cacheable read tier that can be stale, and a small, strictly serialised commit path that cannot. The skill is keeping the second one small. Weak designs let the strict path grow.

Work it through

Five stages, gated. You write your answer for each stage before its model answer unlocks. That is the core of the method.

Loading your progress…

Reference material for this problem. The diagrams below are here to check your own against once you have drawn it, not to read first.

The lifecycle

AvailablesystemHeldbuyerPaidproviderConfirmedsystemTwo claim at onceconditional update — one row updated, one winnerBuyer abandonsexpiry timestamp checked in the claim query, not by a sweeperHold lapses mid-paymentextend once on payment start; else void the authorisationConfirmation write failsvoid the authorisation; reconciliation job catches the restClient retries paymentidempotency key — one charge, replayed responseEvent cancelledbulk release and refund path
Follow the seat, not the buyer. The seat is the contested resource, and every interesting failure is a branch off its life.

The numbers

Requests in the first minute~100,000 / sec (mostly reads)
Hold attempts~20,000 / sec
Successful claims — for the whole sale50,000 total, ever

Only 50,000 writes will ever succeed, because there are only 50,000 seats. So the hard part is not write throughput. It is rejecting the ~90% of attempts that must fail, cheaply, before they reach the database.

The number that changes your mind: the contended path is tiny.

The architecture

browse (stale ok)buyclaimBuyerCDNevent page, seat mapWaiting roomadmits at a fixed rateBooking svcSeats + ordersconditional updatePaymentsauth → capturePost-purchase queueEmail, tickets, reporting
The line down the middle is the design: everything left of the claim can be stale and cached; the claim itself is one exact conditional write.

The mechanism

CallerBuckettokens left: 1API
a token was there — request passesbucket empty — rejected at the door, cheaply
The waiting room in one picture: admit at a rate the exact tier can actually serve, and reject the rest at the door where rejecting is cheap.

The decision this turns on

A
Conditional update on the seat row
  • —One statement: set held where still available. Atomic, no gap between check and write.
  • —The database is already a single point of serialisation — use it.
  • —No lease to expire, so no scenario where two holders both believe they won.
  • —Write ceiling per partition, which the seat count guarantees you never reach.
B
Distributed lock (Redis or similar)
  • —Familiar pattern, and the right tool when there is no shared serialisation point.
  • —Adds a component that can fail, on the most correctness-critical path you have.
  • —A paused process can lose its lease without noticing — two holders, one seat.
  • —Needs fencing tokens to be safe, which is more machinery than the alternative.
Which one: The conditional update. You already have one place where the truth lives, and making it decide is simpler and safer than adding a lock in front of it. Being able to explain why you rejected the lock is worth more here than being able to implement one.
How you claim a seat. This is the decision the whole problem turns on.

Follow-ups you should expect

Many interviews are decided in the follow-ups, not the first diagram.

Same shape, other products

A fixed number of things and more people who want them than there are things.

If two people can end up with the same one, you have failed, no matter how fast the system is. Caching the availability number is the classic wrong turn.

Others with this shape: Ticket booking · Hotel booking · Flash sale · Seat reservation

Before you leave
Try this problem again in a week without reading anything. Comparing what you write then with what you wrote today shows you what stuck.
Stuck, or new to this?Read the full worked solutionAll five stages end to end, with the diagrams and the costs, written out the way you would say it. Best read after you have had a go.Read it →

How it is really built

Write your own answer first. Then read how teams who run this at scale describe it. Their constraints were real, and the write-ups often say what their choices cost.