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.
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:
Reserved or general admission? Reserved seats need a row per seat. General admission needs only a counter.
How big is the hottest onsale? Seats, and how many people arrive at the start. Example: 50K seats, 2M people.
Can users pick exact seats, or does the system pick the best available?
How long may a user hold seats? Usually 5 to 10 minutes. Is there a per-order ticket limit?
Do we need anti-bot and fairness rules? For hot events, yes.
Functional
Non-functional
Browse events and view the seat map
Never oversell: strong consistency on claims
Hold seats for a few minutes
Absorb a spike 100× normal traffic
Pay and confirm, exactly once
Fair: a clear place in line, no random failures
Release holds that expire
Seat 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.
Step
Working
Result
Admit rate
20K concurrent shoppers ÷ 300 s each
~67 users/s
Orders to sell out
50K seats ÷ 2 per order
25K orders
Users to admit
25K ÷ 0.8 conversion
31,250 users
Queue status polling
2M users ÷ 10 s poll interval
200K req/s (at the edge)
Seat-claim writes
20K shoppers × ~3 claim attempts ÷ 300 s
~200 writes/s
Time to sell out
About 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.
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:
Never oversell. The confirmed count must never exceed the real inventory, no matter how many requests collide.
Survive the spike. The system has to absorb an onsale burst that may be orders of magnitude above steady-state traffic.
Be fair and predictable. Users need a coherent experience — a clear place in line, a clear answer about whether they got a seat — rather than random failures.
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 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.
Database row lock (pessimistic). The buyer's transaction locks the seat's row (a 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 concurrency / compare-and-set. No lock is held. The buyer reads the seat with a version number and attempts an update that only succeeds if the version is unchanged. If another buyer got there first, the version moved, the update affects zero rows, and this buyer is told to retry or pick another seat. Cheap when contention is moderate.
Atomic decrement of remaining inventory. For general-admission tickets where seats are interchangeable, you do not track individual seats — you keep a single counter of remaining tickets and decrement it atomically, rejecting any decrement that would take it below zero. One atomic operation enforces the cap.
# 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.
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:
Load shedding. The booking service only ever sees as much traffic as it can handle. The spike is absorbed by the queue instead of the database.
Fairness. Admitting people roughly in arrival order is far better than the random success-and-failure of an unprotected free-for-all.
Honesty. A user who has no realistic chance can be told their position up front, rather than being failed after they have already invested effort.
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.
Inventory is strongly consistent. The seat count and claim operations run against a store that guarantees a single, agreed-upon truth, even if that limits raw throughput.
Reject rather than oversell. When the system cannot be certain a seat is free, it declines the request. A failed booking is recoverable; an oversold seat is not.
Read-heavy paths can relax. Browsing the seat map or event listings can tolerate slightly stale data and be served from caches — but the moment of claiming a seat cannot.
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.
Queue absorbs the burst. The virtual waiting room is the primary spike defense, converting a stampede into a steady, sustainable trickle into the booking service.
Cache the read path. Seat maps, event details, and availability summaries are served from caches so the strongly consistent store is reserved for the actual claim operations.
Keep claims fast and atomic. Whether via row locks, compare-and-set, or atomic decrement, each claim is a single quick operation, minimizing how long any hot seat is contended.
Isolate the hot event. A single blockbuster onsale can be partitioned onto dedicated capacity so it does not degrade booking for every other event on the platform.
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 fails
What happens
How to handle it
Hold expires during a slow payment
Seat resold; first buyer charged
Conditional mark_booked; refund if it fails; extend the hold at Pay
User double-clicks Confirm
Two charges
Idempotency key claimed first, passed to the processor
Sweeper races a confirm
Just-booked seat reset to available
Sweeper's update re-checks status='held' AND expires_at < now
Inventory DB primary fails
Claims stop
Fail over to a synchronous replica; reject claims meanwhile rather than guess
Waiting room fails
Herd hits the booking tier
Fail closed: pause the sale rather than let the herd through
Worked example: a stale callback from an older hold
User U obtains the same seat twice, first H1, later H2.
Read top to bottom. The table below explains the state and guarantee at each step.
Step
State, calculation, and reasoning
1. Create H1
Seat S is held by U with hold_id=H1 and expiry t=300.
2. Expire H1; create H2
U later obtains H2. Same user and seat, different acquisition identity/version.
3. 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.
4. Confirm H2
Use 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.
Clarify reserved vs GA, onsale size, hold time, fairness needs.
5–10
Estimate: show that claims are small but polling is huge.
10–18
API, seat state machine, data model, the high-level diagram.
18–30
Deep dive on the atomic claim, then on the waiting room.
30–38
Payment and confirm: idempotency, the hold-expires-mid-payment race, the sweeper.
38–45
Failure 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
Read-then-write claims without an atomic condition. Two buyers both see "available."
A compare-and-set that does not change the compared value. Both racers win.
Booking without re-checking the hold after a slow payment.
An unconditional sweeper that can undo a confirm.
Scaling the database instead of shielding it with a waiting room.
Holding seats in Redis alone with no durable record. A failover loses holds or double-grants.
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:
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.
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
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'.
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.
With 20K concurrent shoppers and 5 minutes of checkout each, what is the admit rate? How long to admit 31,250 users?
Answer
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.
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.
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
M. Kleppmann, Designing Data-Intensive Applications, chapter 7 ("Transactions"): lost updates, compare-and-set, write skew, and the meeting-room booking example.