Designing a Ticket Booking System

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.

Contents

  1. The Core Challenge
  2. The Reservation / Hold Pattern
  3. Preventing Double-Booking
  4. The Virtual Waiting Room
  5. Idempotent Payment
  6. Releasing Expired Holds
  7. Consistency over Availability
  8. Scale at Onsale
  9. Summary

1. The Core Challenge

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:

A useful reframing for an interview: this is not a web-app problem, it is a distributed counting problem under contention. Every design choice — holds, locks, the waiting room — exists to enforce an exact inventory count while a crowd races for the last seats.

2. The Reservation / Hold Pattern

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.

Seat states: available, held with a TTL, booked, or released
A seat starts available. When a user selects it, it moves to held with a time-to-live while they check out. On confirmation it becomes booked; if the hold's TTL expires or the user abandons, it is released back to available.

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:

StateMeaningTransitions out
AvailableUp for sale, anyone can take it.→ Held, when a user selects it.
HeldReserved for one user, TTL ticking.→ Booked on confirm; → Available on expiry/abandon.
BookedPaid 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.

3. Preventing Double-Booking

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.

Two buyers race for one seat; a lock or compare-and-set lets one win
Two buyers race for the same seat. An atomic lock or compare-and-set on that seat lets exactly one of them win; the other is cleanly rejected and offered alternatives. No matter how the requests interleave, the seat is sold once.
# 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
Whichever mechanism you choose, the principle is the same: the decision "this seat is now mine" must be a single atomic step that exactly one racer can win. Everything else about correctness rests on that.

4. The Virtual Waiting Room

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.

A waiting room admitting users at a controlled rate into the booking service
The thundering herd lands in a waiting room and is given a queue position. A rate-limiting gate releases users into the booking service only as fast as it can safely handle them, so the inventory store behind it is never overwhelmed.

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)

5. Idempotent Payment and Confirm

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
Any operation that moves money must be idempotent. The idempotency key turns "did this confirm already happen?" from a guess into a lookup, so a retried request can never produce a second charge or a second seat.

6. Releasing Expired Holds

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.

7. Consistency over Availability

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.

The trade-off is deliberate: you accept lower availability and some rejected requests on the booking path in exchange for an absolute guarantee that inventory is never oversold. For tickets, correctness beats uptime.

8. Scale at Onsale

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.

9. Summary

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:

ConcernMechanism
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.
The recurring theme: protect an exact inventory count under contention. Holds give buyers time without losing the count, atomic claims make every seat sell exactly once, the waiting room flattens the herd, idempotency tames retries, and choosing consistency guarantees you never sell a seat you cannot deliver.