Skip to content
Preptima
hardCase StudyDesignMidSeniorStaff

Design ticketing for a 60,000-seat stadium where every seat goes on sale at 10am. How do you sell each seat exactly once?

Selling a specific seat is a uniqueness problem, not a throughput one, so the seat row is the serialisation point and the design lives or dies on the reservation hold: a short expiring claim written atomically, plus a queue in front so the contention never reaches the database at full force.

7 min readUpdated 2026-07-29Target archetype: Big Tech, Enterprise Captive, Product Startup
Practice answering out loud

What the interviewer is scoring

  • Does the candidate identify the seat row as the point that must serialise, rather than reaching for a cache
  • Whether the hold is given an explicit expiry and an owner, instead of being a boolean flag on the seat
  • That the payment step is treated as a slow external call that can time out after succeeding
  • Whether the admission mechanism in front of the sale is separated from the transaction behind it
  • Can they say what the system does when a hold expires while the payment provider is still deciding

Answer

The requirement that decides everything

Two rules matter and they pull in opposite directions. A seat must never be sold twice, because the second buyer arrives at a stadium and is turned away, which is a legal and reputational failure rather than a technical one. And a seat must not be held indefinitely by somebody who wandered off, because unsold inventory in a two-hour sale window is unrecoverable revenue.

So this is not a scale problem dressed up as a correctness problem, it is the reverse. Sixty thousand seats is a tiny dataset. Two hundred thousand people trying to buy them in the first three minutes is the load, and almost all of that load is contention on the same few thousand rows — the good seats — rather than volume spread across a large keyspace. Say that early, because it rules out the reflexive answer. Sharding does not help when everybody wants the same shard, and caching does not help when the thing being read is the thing being mutated.

Before designing, pin down whether seats are individually identified or general admission, whether adjacency matters, and how long a buyer gets to complete payment. Those three answers change the design more than any traffic figure.

The seat row is the serialisation point

Every seat needs a single authoritative record whose state transitions are serialised. The states worth having are available, held, and sold, with held carrying who holds it and when the hold expires. That expiry is the whole design, because it is what lets you be strict about double-selling without being permanent about it.

The claim must be one atomic operation that both checks and mutates. In a relational store that is a conditional update — set the row to held with this holder and this expiry, only where the row is currently available or where its existing hold has already expired — and you inspect how many rows it affected. If it affected zero, somebody else got there first, and you tell the buyer so immediately rather than proceeding hopefully. Reading the seat, deciding in application code that it is free, and then writing is the classic wrong shape: two callers both read available, both decide, both write, and the second silently overwrites the first.

-- One statement decides the race. The row count is the answer:
-- 1 means you hold the seat, 0 means someone else does.
UPDATE seats
   SET state = 'held', holder = :session, hold_expires_at = now() + interval '8 minutes'
 WHERE seat_id = :seat
   AND (state = 'available'
        OR (state = 'held' AND hold_expires_at < now()));

The reason the expiry condition is inside the same statement rather than in a cleanup job is that it makes reclamation lazy and correct. There is no window in which a background sweeper and a buyer disagree about whether an old hold is still valid, because the only place that judgement is made is at the moment somebody tries to take the seat. A sweeper is still worth running, but only so that the seat map shown to browsers is fresh, never as the mechanism that enforces the rule.

Pessimistic locking is the alternative and it is defensible for a single seat, since the lock is held for microseconds. It becomes a problem when a purchase covers four seats, because two buyers acquiring overlapping sets in different orders deadlock. Acquire in a deterministic order — ascending seat identifier — and that class of deadlock disappears.

Do not let the sale reach the database at full force

The second half of the design is admission control, and it is the part candidates skip. Even with a perfectly correct seat claim, two hundred thousand simultaneous conditional updates against a few thousand contended rows produces lock convoys, connection-pool exhaustion and timeouts, and the observable outcome is that nobody buys anything rather than that the fast buyers win.

The standard shape is a virtual waiting room. On arrival, every user is issued a signed queue token and served a lightweight waiting page that polls for their position; a controlled trickle of tokens is admitted into the purchase path at a rate the seat database is measured to sustain. The queue is the throughput knob and the database is the correctness mechanism, and keeping those two jobs in separate components is what makes the system tunable during a sale rather than only before one.

flowchart LR
  A[Buyer arrives] --> B[Queue token issued]
  B --> C{Admitted at metered rate}
  C -- waiting --> B
  C -- admitted --> D[Conditional claim on seat row]
  D -- zero rows --> E[Seat gone, reselect]
  D -- one row --> F[Hold with expiry]
  F --> G[Payment, then sold]

The edge worth pointing at is the loop back from admission to waiting: a buyer who is admitted and loses the race returns to selection rather than to the back of the queue, and if you get that wrong you have built a system that punishes people for being nearly first.

Two properties make the queue honest. The token must be issued once per person and be unforgeable, or a script simply requests a thousand tokens and the queue becomes a lottery weighted towards whoever automates best. And the queue page must be servable from static infrastructure with no dependency on the seat database, because the entire point is to be the thing that stays up when the purchase path is saturated.

Payment is a slow call that can lie to you

The hold exists because payment is slow and external. That creates the situation this design must answer for: you send an authorisation, the connection times out, and you do not know whether the charge succeeded.

Retrying blindly risks charging twice. Abandoning risks a customer who has been charged with no ticket, which is the worst outcome available. So the purchase attempt gets an identifier of its own, generated before the payment call and sent with it as an idempotency key, and the outcome is reconciled rather than assumed: query the provider for that key until it gives a definite answer. Meanwhile the seat stays held under that attempt, not released, because releasing a seat whose payment may have succeeded is how you sell it twice.

That means the hold expiry cannot be the only thing that frees a seat. A hold in payment-pending state must be excluded from lazy reclamation and resolved by the reconciliation path instead, which either converts it to sold or, once the provider confirms failure, returns it to available. Getting this distinction — between a hold nobody is using and a hold whose fate is genuinely unknown — is the difference between an answer that has shipped a payment integration and one that has read about one.

The state that follows is deliberately terminal. Once sold, the seat record does not go back to available through the same path; a refund or a resale is a new transaction that writes a new state, so that an ordinary expiry bug can never resurrect a sold seat.

Where these systems actually fall over

The failure to walk through, because interviewers ask for it, is the queue emptying faster than the database drains. Admission is set from a load test done against warm caches and an empty seat table. In the real sale, the contended rows are hot, each conditional update waits behind others, and effective throughput is a fraction of the tested figure. Admission keeps metering people in at the tested rate, the purchase path saturates, requests time out, and clients retry, which adds load precisely when there is none to spare. Holds created just before the timeouts sit unresolved because the payment callbacks are queued behind the retries, so inventory looks sold out while nothing has been sold.

Three things prevent that specific cascade. Drive admission from a measured signal — purchase completions per second, or the seat database's own queue depth — rather than from a fixed rate, so the meter closes when the system slows. Bound the purchase path with a concurrency limit and shed beyond it, so a saturated database returns a fast refusal instead of a slow timeout. And make the client's retry an idempotent replay of the same attempt identifier rather than a new attempt, so a retry storm cannot manufacture holds.

The seat row is where correctness lives and the queue is where throughput lives, and conflating them is the mistake: an atomic conditional claim with an explicit expiry makes double-selling impossible, and metered admission is what stops two hundred thousand people from proving your database cannot do it fast enough.

Likely follow-ups

  • Fifty thousand people click the same block of four adjacent seats. How do you allocate contiguous seats without a global lock on the block?
  • The payment provider times out and you never learn the outcome. What state is the seat in, and for how long?
  • How do you support a general-admission tier with no seat identity in the same system, and does the design get simpler or harder?
  • Resale opens and a ticket can change hands twice in a minute. What has to change in the seat record?

Related questions

reservation-systemsconcurrency-controlinventoryidempotencyvirtual-queue