A system design interview guide to selling a fixed, limited inventory of seats under heavy contention — a Ticketmaster-style onsale where a huge crowd arrives at once, everyone wants the same few seats, and you must never sell the same seat twice.
A ticket booking system looks like a simple CRUD app right up until the moment a popular event goes on sale. Then tens of thousands of people hit the same endpoint in the same second, all competing for the same finite pool of seats, and the design problem becomes one of the hardest in distributed systems: enforcing a strict count of inventory under extreme concurrency without ever overselling. You cannot apologize your way out of selling seat 12A to two different people. This guide builds a design that survives the thundering herd at onsale, holds seats safely while a buyer checks out, prevents double-booking with proper concurrency control, sheds load with a waiting room, and confirms payment idempotently — all while choosing consistency over availability for the one thing that must never be wrong: the seat count.
Strip the system down and the difficulty is a single sentence: there is a fixed number of seats and, for a hot event, a vastly larger number of people trying to buy them in the same instant. Two facts make this brutal. First, inventory is hard-limited — once the seats are gone they are gone, and the count must be exact. Second, demand arrives as a thundering herd: a sharp, coordinated spike at the onsale moment rather than a smooth stream.
The cardinal sin is overselling — confirming more buyers than there are seats. It is not a glitch you can paper over; it means someone shows up to the venue without a seat. Avoiding it under massive concurrency, when thousands of requests are racing to grab the last few seats at once, is the entire game. A few requirements fall out of this immediately:
The key insight that makes checkout workable is that buying a seat is not instantaneous — the user needs time to enter payment details and confirm. If a seat stayed "available" during that window, two people could both pay for it. So a seat moves through a small lifecycle, and the middle state is a temporary hold.

The hold is best understood as a short-lived lease on the seat. When a user picks a seat, the system reserves it for them for a bounded window — say a few minutes — with an attached TTL. During that window no one else can take it. The user then either confirms (the seat becomes booked) or lets the clock run out (the seat is released back to the pool). The states are:
| State | Meaning | Transitions out |
|---|---|---|
| Available | Up for sale, anyone can take it. | → Held, when a user selects it. |
| Held | Reserved for one user, TTL ticking. | → Booked on confirm; → Available on expiry/abandon. |
| Booked | Paid for and confirmed; terminal. | (stays booked) |
function hold_seat(user_id, seat_id):
if not try_claim(seat_id, holder=user_id, ttl=5min):
return SEAT_TAKEN # someone got it first
return HELD # reserved; user can now check out
function confirm(user_id, seat_id, payment):
if not holds(seat_id, by=user_id):
return HOLD_EXPIRED # TTL ran out before checkout
charge(payment)
mark_booked(seat_id, user_id)
return BOOKED
The TTL is what keeps the system honest: it guarantees a seat can never be stranded forever by a user who walked away mid-checkout. Without an expiring hold, abandoned carts would slowly lock up the entire inventory.
The hold pattern only works if claiming a held seat is atomic. The dangerous moment is when two buyers try to grab the same available seat at the same instant. If the check ("is it free?") and the claim ("take it") are separate steps, both can read "free" and both can take it — a classic race that ends in a double-booking. The fix is to make the read-and-claim a single indivisible operation. There are three standard ways to do it.

SELECT ... FOR UPDATE), checks it is available, and updates it to held — all inside one transaction. Any concurrent buyer blocks on the lock and, by the time it proceeds, sees the seat is taken. Simple and correct, but the lock can become a contention bottleneck on a single hot seat.# optimistic: claim only if the seat's version hasn't changed
function try_claim(seat_id, holder):
seat = db.get(seat_id)
if seat.status != "available":
return False
updated = db.update(
seat_id, set={status: "held", holder: holder},
where={version: seat.version}) # compare-and-set
return updated.rows == 1 # 0 rows = someone beat us
# general admission: one atomic decrement guards the cap
function take_one(event_id):
remaining = db.decr_if_positive(event_id) # atomic; refuses below zero
return remaining is not None
Atomic claims keep the data correct, but they do nothing to protect the system from being flattened by the onsale spike. If a hundred thousand people hit the booking service in the same second, the database and application tier fall over regardless of how careful the locking is. The answer is to put a virtual waiting room in front of the booking flow and admit users at a controlled rate.

The waiting room turns a chaotic stampede into an orderly line. Everyone who arrives is parked in a queue and given a position; a gate then drips them through into the actual booking service at a rate the service can sustain. This does three things at once:
function enter_waiting_room(user_id):
pos = queue.push(user_id) # everyone gets a place in line
return {position: pos}
# the gate admits at a sustainable rate (token bucket)
function admit_loop():
while True:
if booking_service.has_capacity():
user = queue.pop() # release one into checkout
grant_access(user, ttl=10min)
sleep(admit_interval)
Once a user is admitted and holding a seat, they pay and confirm. Payment is where retries are most dangerous: networks drop, users double-click, and a confirm request may legitimately arrive more than once. If the system processes the same confirmation twice, it could double-charge the user or tangle the booking. The defense is idempotency.
Each confirm request carries a unique idempotency key. The first time the system sees that key it performs the charge and books the seat and records the outcome against the key. If the same key arrives again — a retry — the system recognizes it, skips the duplicate work, and returns the original result. The buyer is charged once and gets one seat no matter how many times the request is replayed.
function confirm_booking(idempotency_key, user_id, seat_id, payment):
existing = idempotency.get(idempotency_key)
if existing:
return existing.result # replay: return prior outcome
charge_once(payment) # the money-moving step
mark_booked(seat_id, user_id)
result = {status: "BOOKED", seat: seat_id}
idempotency.put(idempotency_key, result) # remember it for retries
return result
Holds exist to give buyers time, but every hold that is not confirmed must eventually return its seat to the pool — otherwise abandoned carts would slowly starve the inventory. The TTL on each hold is the mechanism, and the system needs a reliable way to act on expiry so that a seat held by someone who wandered off becomes buyable again.
There are two common approaches, and robust systems often combine them:
# background sweep: reclaim seats whose hold TTL has passed
function sweep_expired_holds():
for hold in db.holds.where(expires_at < now(), status == "held"):
db.update(hold.seat_id, set={status: "available", holder: null})
log.info("released expired hold", seat=hold.seat_id)
Releasing promptly matters most precisely when demand is highest: at onsale, a seat freed a minute after an abandoned checkout is a seat another eager buyer can still grab.
Distributed systems force a choice when a network partition happens: do you keep serving requests with possibly-stale data (availability), or do you refuse rather than risk being wrong (consistency)? For ticket inventory the answer is unambiguous — choose consistency. It is far better to tell a user "try again in a moment" than to confirm a seat you cannot actually deliver.
This is the philosophical core of the design. Inventory is the one piece of state that must be strongly consistent, because the cost of an inconsistency is a real person denied a real seat. Concretely, that means the seat count and its atomic claims live in a strongly consistent store, and under failure the system prefers to reject a booking attempt rather than allow two nodes to independently sell the same seat.
Everything above has to hold up under the single hardest moment: the onsale spike. The traffic pattern is extreme and short — a massive burst, then a rapid taper as seats sell out — so the system is tuned to absorb and flatten that burst rather than to scale infinitely.
The unifying idea is to protect the one scarce, consistency-critical resource — the inventory store — by keeping everything else (queuing, browsing, payment processing) off its back. The queue flattens the spike, caches absorb the reads, and the consistent core does only the irreducible work of claiming seats exactly once.
A ticket booking system is a distributed counting problem: sell an exact, limited inventory under a thundering herd without ever overselling. Its design is a set of reinforcing decisions:
| Concern | Mechanism |
|---|---|
| What makes this hard? | Fixed inventory plus a thundering herd at onsale; overselling is unacceptable. |
| How do users hold a seat while checking out? | A reservation with a TTL: available → held → booked, or released on expiry. |
| How do we prevent double-booking? | Atomic claims: DB row locks, optimistic compare-and-set, or atomic inventory decrement. |
| How do we survive the onsale spike? | A virtual waiting room that queues users and admits them at a controlled rate. |
| How do we avoid double-charging? | Idempotent payment and confirm keyed by an idempotency key. |
| How do abandoned holds get freed? | TTL-based release via lazy expiry checks plus a background sweep. |
| What do we do under failure? | Choose consistency over availability — reject rather than oversell. |
| How do we handle onsale scale? | Queue absorbs the burst, caches serve reads, the consistent core only claims seats. |