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.

Student edition · start with the guarantee

Hold scarce seats during checkout and reject stale payment or expiry callbacks.

Correctness contract: A seat has at most one current owner; transitions check exact hold identity/version and expiry. Charge/refund operations have distinct stable idempotent identities.

Basic HTTP, databases, and programming are assumed. Specialized terms are explained below. Named products motivate the problem; this is an educational design, not a claim about their current internals. Capacity numbers are illustrative decimal payload estimates, not benchmarks. Add indexes, protocol overhead, replicas, headroom, and failure capacity before provisioning.

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. Requirements & Clarifying Questions
  2. Back-of-Envelope Estimation
  3. API & Data Model
  4. The Core Challenge
  5. The Reservation / Hold Pattern
  6. Preventing Double-Booking
  7. The Virtual Waiting Room
  8. Idempotent Payment and Confirm
  9. Releasing Expired Holds
  10. Consistency over Availability
  11. Scale at Onsale
  12. Failure Modes
  13. Tradeoffs & Alternatives
  14. A 45-Minute Interview Walkthrough
  15. Common Mistakes
  16. Summary
  17. Check Yourself
  18. Key Terms
  19. Further Reading
  20. Concepts to understand first
  21. Worked example
  22. Runnable Python model

Concepts to understand first

Each definition includes a concrete example and the condition under which the mechanism is useful. You can read this guide without opening another guide.

Optimistic concurrency and compare-and-set

Update state only if the expected version or status still matches, in the same atomic operation that changes it. A failed comparison means a conflicting operation won; reread or return a conflict.

Example. UPDATE seats SET status='held', version=version+1 WHERE id=? AND status='available' allows only one concurrent claimant to update the row.

Limit. Application-level GET then unconditional SET is not equivalent. Include acquisition identity to reject stale callbacks, and couple related invariants in a transaction.

Leases

Time-limited permission issued by an authority. Expiry lets new work proceed after a client stops renewing; it does not prove that the previous holder is dead or unable to act.

Example. A 30-second lease expires while A is paused for 45 seconds. B can acquire a new lease, but A can still wake up and send a delayed write.

Limit. Heartbeats improve recovery time, not safety by themselves. Compare-and-renew/delete must verify a unique acquisition token atomically, and protected effects need fencing or equivalent serialization.

Idempotency keys

A client-generated identity for one logical operation. The server binds it to caller scope, endpoint, request fingerprint, state, and result. A retry uses the same key and reuses the original outcome rather than repeating the effect.

Example. Payment p42 times out after the provider charged it. Retrying with p42 lets the provider return the same charge instead of creating a new one.

Limit. A unique local claim is insufficient if the business effect and saved result can diverge. Keep unknown outcomes pending, use downstream idempotency, and state the retention/retry horizon.

Sagas and compensation

A durable workflow of local transactions with corrective actions for failures. Compensation is a new business operation, not a rollback of history. The workflow exposes intermediate states and must recover after crashes.

Example. Reserve a seat, authorize payment, confirm booking, then capture. A declined authorization releases the seat; an uncertain charge stays pending until reconciled.

Limit. Timeout does not mean the remote effect failed. Compensation can fail too and needs retries/escalation. A saga does not provide isolation or guarantee every external effect is reversible.

Fencing tokens

A strictly increasing grant number attached to operations. The protected resource atomically compares it with its durable highest accepted token and rejects older operations, making superseded clients unable to overwrite newer effects.

Example. A has token 33 and pauses. B acquires 34 and writes. When A sends token 33 later, the resource rejects it because its fence is already 34.

Limit. A new grant must be installed at the resource before it promises exclusion there. Token 33 can still pass until 34 is observed. Equal-token operations also need sequence/idempotency rules if they can arrive out of order.

Backpressure and load shedding

Backpressure tells an upstream producer that the downstream stage cannot accept more work. Buffering delays that signal; shedding deliberately rejects or drops lower-priority work when a bound is reached.

Example. A sink accepts 100 events/s while input is 150/s. A 1000-event queue fills in 20 seconds. The system must then block, reject, or apply its documented drop policy.

Limit. A finite queue cannot absorb an infinite outage. Prioritized messages still need hard quotas, deadlines, and bounded client queues; otherwise an exemption can exhaust the system.

1. Requirements & Clarifying Questions

"Design Ticketmaster" hides several different problems. Ask first:

FunctionalNon-functional
Browse events and view the seat mapNever oversell: strong consistency on claims
Hold seats for a few minutesAbsorb a spike 100× normal traffic
Pay and confirm, exactly onceFair: a clear place in line, no random failures
Release holds that expireSeat map may be a few seconds stale
Intuition

Think of a bakery with 50 loaves and 2,000 people outside. A good bakery does not let everyone rush the counter. It hands out numbered tickets at the door, lets a few in at a time, and each baker puts a loaf aside for you while you find your wallet. If you wander off, the loaf goes back on the shelf.

2. Back-of-Envelope Estimation

Hot onsale: 50K seats, 2M people arrive, average order of 2 seats, 80% of admitted users buy. Checkout takes about 5 minutes (300 s), and the booking tier is sized for 20K shoppers at once.

StepWorkingResult
Admit rate20K concurrent shoppers ÷ 300 s each~67 users/s
Orders to sell out50K seats ÷ 2 per order25K orders
Users to admit25K ÷ 0.8 conversion31,250 users
Queue status polling2M users ÷ 10 s poll interval200K req/s (at the edge)
Seat-claim writes20K shoppers × ~3 claim attempts ÷ 300 s~200 writes/s
Time to sell outAbout 467 seconds of steady-state admissions at 67/s, assuming the stated conversion/checkout mix. Cold-start ramp, checkout duration, abandoned/expired holds, and payment failures affect elapsed sellout time.~470 s ≈ 8 min
What the numbers tell you

The database only sees about 200 claim writes per second, which is easy. The hard part is the 200K requests per second of people refreshing and polling. So serve queue status and seat maps from the CDN or edge. Protect the database with the waiting room, not with a bigger database. Also, most of the 2M people will not get tickets. Tell them early.

3. API & Data Model

GET  /events/{id}/seatmap                      -> cached, may be seconds stale
POST /events/{id}/queue                        -> { queue_token, position }
GET  /queue/{queue_token}                      -> { position } | { admit_token }
POST /events/{id}/holds   (admit_token)        { seat_ids: [...] } -> { hold_id, expires_at }
POST /holds/{hold_id}/confirm  Idempotency-Key { payment_token } -> { booking_id } | 410 HOLD_EXPIRED
seats(event_id, seat_id, status, holder, expires_at, version,
      PRIMARY KEY (event_id, seat_id))       -- status: available | held | booked
bookings(id PK, event_id, user_id, seat_ids, payment_ref, idempotency_key UNIQUE)
ga_inventory(event_id PK, remaining INT CHECK (remaining >= 0))   -- general admission

Holding several seats at once must be all-or-nothing. Claim them in one transaction, in seat-id order to avoid deadlocks. Use UPDATE … WHERE seat_id IN (…) AND status='available' and roll back unless the row count equals the number of seats asked for.

High-level design: shield the inventory store 2M usersat onsale CDN / edgeseat map, polling Waiting roomadmit ~67/s Booking servicechecks admit token Inventory DBatomic claims Paymentidempotency key Hold sweeper the herd waits here
Reads and polling stay at the edge. The waiting room turns millions of arrivals into a steady trickle. Only admitted users reach the booking service, which makes one atomic claim per seat. A sweeper returns expired holds to the pool.

4. 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.

5. The Reservation / Hold Pattern

Each acquisition needs hold_id, owner, seat/order IDs, version, and authoritative expiry. User identity is not acquisition identity. Claim, book, and release are atomic conditional writes. Multi-seat orders need an all-or-nothing transaction or explicit partial-hold policy.

Book only if status, exact hold_id/version, ownership, and expiry still match. Use a consistent boundary, for example valid when now<expires_at. Stale callbacks/sweepers cannot alter newer holds. Expiry requires lazy checks/conditional cleanup and recovery; a TTL alone does not prove business rows are eventually released during permanent failure.

Prefer authorize→conditional booking→capture when supported, with durable recovery. If charging first, failed booking triggers a new idempotent refund that may be delayed/fail. Timeouts are unknown and require reconciliation.

6. 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 send a compare-and-set for seat 12A at version 7; the first gets 1 row, the second gets 0 rows
Two buyers race for the same seat. Each sends a compare-and-set that only updates the row if the seat is still available at the version they read, and bumps the version. The first update wins with 1 row; the second matches 0 rows and is offered other seats. A row lock works too. 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, ttl):
  seat = db.get(seat_id)
  if seat.status != "available":
    return False
  updated = db.update(
    seat_id,
    set={status: "held", holder: holder,
         expires_at: now() + ttl,
         version: seat.version + 1},         # MUST bump the version
    where={version: seat.version,
           status: "available"})             # compare-and-set
  return updated.rows == 1                    # 0 rows = someone beat us

# the same claim as one SQL statement, no read needed
UPDATE seats SET status='held', holder=:user, expires_at=now() + interval '5 min'
 WHERE id=:seat AND status='available';     -- 1 row = you won

# 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
Common mistake: a version check that never moves

Compare-and-set only works if the winner changes the thing being compared. If the update checks version = 7 but leaves the version at 7, a second racer that also read 7 still matches, and both "win." Bump the version in the same update, or put the real condition (status = 'available') in the WHERE clause.

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.

7. 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.

Onsale crowd passes a bot filter into a waiting room; a gate admits users at a safe rate with a signed token
The onsale crowd passes a bot filter and lands in a waiting room, where early arrivals get a random place in line. A rate-limiting gate admits users only as fast as the booking service can handle and gives each a signed token. The booking service rejects calls without a valid token, so no one can skip the line.

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 (a simple rate-limited loop)
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)

Real waiting rooms add three details. Early arrivals are shuffled: everyone who arrives before the sale opens gets a random place, so refreshing at 9:59:59 does not beat someone who waited an hour. Admission is a signed token checked at the booking service, so users cannot skip the line by calling the API directly. And bots are filtered at the edge, since scalpers are the main source of the herd.

8. Idempotent Payment and Confirm

Bind the confirmation operation to caller, payload, and hold_id. Local booking/outcome share one transaction. Charge, capture, and refund are different logical effects: use distinct stable keys such as charge:H1 and refund:H1, following provider endpoint scope.

Retries replay the outcome or show reconciling. A crash after charging does not justify blindly reclaiming the key. Query/retry the original provider identity. A stale H1 callback cannot book H2 even for the same user/seat. Refund failure remains durable pending compensation, not a discarded exception.

Cached seat maps and waiting-room admission do not grant inventory ownership. Personalized queue tokens/status must not leak via a shared CDN cache. During lost authority, reject/defer rather than grant from stale cache.

9. Releasing Expired Holds

Lazy expiry and background sweep can cooperate, but release must check exact hold_id/version and authoritative expiry atomically. Selecting an old row before an unconditional UPDATE is racy.

UPDATE seats SET status='available', hold_id=NULL, version=version+1
 WHERE id=:seat AND status='held' AND hold_id=:old_hold
   AND version=:old_version AND expires_at <= :authority_now;
-- 0 rows: no longer that expired hold.

Monitor cleanup lag and pending-payment policy. Do not reuse hold IDs or silently reclaim uncertain external charges.

10. 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.

11. 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.

12. Failure Modes

What failsWhat happensHow to handle it
Hold expires during a slow paymentSeat resold; first buyer chargedConditional mark_booked; refund if it fails; extend the hold at Pay
User double-clicks ConfirmTwo chargesIdempotency key claimed first, passed to the processor
Sweeper races a confirmJust-booked seat reset to availableSweeper's update re-checks status='held' AND expires_at < now
Inventory DB primary failsClaims stopFail over to a synchronous replica; reject claims meanwhile rather than guess
Waiting room failsHerd hits the booking tierFail closed: pause the sale rather than let the herd through
Bots flood the queueReal fans lose outEdge bot detection, CAPTCHA, verified-fan presale, per-account limits

13. Tradeoffs & Alternatives

ChoiceOption AOption BUsual pick
Claim mechanismRow lock: simple, blocks under contentionConditional update: no waiting, loser retriesConditional update
Where holds liveIn the seat row: one source of truthRedis with TTL: fast expiry, two systemsSeat row; Redis only as a cache in front
ExpiryLazy check on readActive sweeperBoth
AdmissionStrict FIFORandomized for early arrivalsRandom before onsale, FIFO after
Seat selectionUser picks exact seats: more collisionsBest available: fewer collisionsBest available for hot onsales

Worked example: a stale callback from an older hold

User U obtains the same seat twice, first H1, later H2.

Worked example, in orderCreate H1: Seat S is held by U with hold_id=H1 and expiry t=300.. Expire H1; create H2: U later obtains H2. Same user and seat, different acquisition identity/version.. Receive payment callback for H1: Conditional booking checks H1/version/status/expiry and affects zero rows. It must not book or release H2; recover H1's charge using a durable compensation workflow.. Confirm H2: Use distinct charge:H2 and refund:H2 operation identities following provider scope, and a local durable state machine.1Create H12Expire H1; create H23Receive payment callback for H14Confirm H2
Read top to bottom. The table below explains the state and guarantee at each step.
StepState, calculation, and reasoning
1. Create H1Seat S is held by U with hold_id=H1 and expiry t=300.
2. Expire H1; create H2U later obtains H2. Same user and seat, different acquisition identity/version.
3. Receive payment callback for H1Conditional booking checks H1/version/status/expiry and affects zero rows. It must not book or release H2; recover H1's charge using a durable compensation workflow.
4. Confirm H2Use distinct charge:H2 and refund:H2 operation identities following provider scope, and a local durable state machine.
Check your understanding: Is holder user_id enough to identify a reservation?

No. A repeated acquisition by the same user creates an ABA-style race. Match the specific hold ID/version, not only user and seat.

Run a small, testable model

Download concepts.py. It uses only the Python 3.10+ standard library. From the directory where you saved it, run:

python3 concepts.py seats
python3 concepts.py test

The conditional-update model rejects a stale callback after the same user obtains a new hold.

The program contains deterministic assertions and failure traces. It is a learning model, not a production service: it does not implement network consensus, distributed durability, authentication, or third-party delivery. Read its contracts and try changing an input before extending it.

Primary references for these concepts

14. A 45-Minute Interview Walkthrough

MinutesWhat to do
0–5Clarify reserved vs GA, onsale size, hold time, fairness needs.
5–10Estimate: show that claims are small but polling is huge.
10–18API, seat state machine, data model, the high-level diagram.
18–30Deep dive on the atomic claim, then on the waiting room.
30–38Payment and confirm: idempotency, the hold-expires-mid-payment race, the sweeper.
38–45Failure modes, consistency over availability, recap.
Interview tip

Say early that the database load is small once the waiting room is in place. Interviewers often expect you to reach for sharding. Showing that about 200 writes/s is all that reaches the store, and that the real scale problem is read traffic at the edge, stands out.

15. Common Mistakes

16. 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.

17. Check Yourself

  1. Two buyers read seat 12A with version 7. Both run UPDATE seats SET status='held' WHERE id='12A' AND version=7. How many succeed, and what is the fix?
    Answer

    Both succeed, because neither update changes the version, so the second still matches. Bump it (SET version = version + 1) or add AND status='available'.

  2. A user's 5-minute hold runs out while their card is being charged. What should happen?
    Answer

    mark_booked must be conditional on the hold still being theirs and unexpired. If it updates zero rows, refund the charge and tell the user. Extending the hold when they press Pay makes this rare.

  3. With 20K concurrent shoppers and 5 minutes of checkout each, what is the admit rate? How long to admit 31,250 users?
    Answer

    20,000 ÷ 300 ≈ 67 users/s. 31,250 ÷ 66.7 ≈ 469 s, about 8 minutes.

  4. Why does the sweeper's update need WHERE status='held' AND expires_at < now() even though it found the row that way?
    Answer

    Time passes between the find and the update. The user may confirm in between. Without the re-check, the sweeper would reset a booked seat to available.

  5. 2M people poll their queue position every 10 s. What load is that, and where do you serve it?
    Answer

    200K requests/s. Serve it from the CDN or edge with a signed token, never from the inventory database.

  6. For a general-admission show, why is one counter enough, and what stops it going negative?
    Answer

    Tickets are interchangeable, so only the count matters. An atomic UPDATE … SET remaining = remaining - 1 WHERE remaining > 0, or a CHECK (remaining >= 0) constraint, stops it at zero.

18. Key Terms

Overselling
Confirming more buyers than there are seats.
Hold
A short, expiring reservation on a seat during checkout.
TTL
How long a hold lasts before it expires.
Compare-and-set
Update state only if the expected version or status still matches, in the same atomic operation that changes it. A failed comparison means a conflicting operation won; reread or return a conflict. Example and limits above.
Thundering herd
Huge numbers of clients arriving at the same moment.
Virtual waiting room
A queue in front of the booking flow that admits users at a set rate.
Admit token
A signed pass proving a user came through the queue.
General admission
Unnumbered tickets tracked by a single counter.
Lazy expiry / sweeper
Treat old holds as free when read / free them in the background.
Idempotency key
A client-generated identity for one logical operation. The server binds it to caller scope, endpoint, request fingerprint, state, and result. A retry uses the same key and reuses the original outcome rather than repeating the effect. Example and limits above.

19. Further Reading


⌂