Lessons
1 · System Design Foundations
2 · APIs and Communication
3 · Capacity Estimation
4 · Core Building Blocks
5 · Databases
- 12Relational Databases and Data Modeling for System Design
- 13Database Indexes and Query Performance: How to Make Reads Fast
- 14Transactions, Isolation Levels and Concurrency Control: Preventing Race Conditions
- 15Database Replication: Primary-Replica, Read Replicas, Lag and Failover
- 16Database Sharding and Partitioning: How It Works and When You Need It
- 17SQL vs NoSQL: Data Models and How to Choose the Right Database
6 · Distributed Systems
- 18Why Distributed Systems Are Hard: Partial Failure, Timeouts and Clocks
- 19CAP Theorem and Consistency Models Explained Without the Myths
- 20Idempotency, Retries and Message Delivery Guarantees
- 21Distributed Transactions: Two-Phase Commit, the Outbox Pattern and Sagas
- 22Distributed Locks, Leases, Leader Election and Consensus
7 · Reliability and Operations
8 · Architecture and Security
9 · Interview Method and Foundation Case Studies
10 · Real-World Case Studies
- 33Design a Notification System: Email, SMS and Push at Scale
- 34Design a Chat System Like a Messaging App
- 35Design a News Feed: Fan-Out, Ranking and the Celebrity Problem
- 36Design Search Autocomplete (Typeahead)
- 37Design a Distributed Job Scheduler
- 38Design a Ticket Booking System Without Overselling
- 39Design a Payment System: Idempotency, Ledgers and Reconciliation
11 · Large-Scale Case Studies
- 40Design a Video Streaming Platform: Upload, Transcoding and Adaptive Streaming
- 41Design a Ride-Hailing System: Location, Matching and Real-Time Updates
- 42Design a Food Delivery Platform: Orders, Restaurants and Delivery Partners
- 43Design an E-Commerce System: Catalog, Cart, Checkout, Inventory and Flash Sales
- 44Design a Web Search Engine: Crawling, Inverted Indexes and Ranking
12 · Interview Readiness
Lesson 38 · Real-World Case Studies
Design a Ticket Booking System Without Overselling
Design ticketing for concerts and matches where thousands of people want the same seats at the same moment: seat inventory modelling, holds with expiry, atomic all-or-nothing seat claims, idempotent payment and confirmation, what happens when payment succeeds after a hold expires, virtual waiting rooms for big on-sales, hot inventory, bots, failures and the invariant that a seat is never sold twice.
For
SDE-2 candidates preparing for booking and reservation questions, and engineers building any system with scarce, contended inventory.
Level
Before you start
Read Transactions, Isolation Levels and Concurrency Control (article 14), Idempotency (article 20), Distributed Transactions and Sagas (article 21) and Design a Distributed Job Scheduler (article 37).
On this page
Key takeaways
- The defining requirement is that a seat is never sold twice, so seat allocation needs strong consistency on one authoritative copy of each event's inventory.
- Claim seats with atomic conditional updates (or unique constraints), all-or-nothing for multi-seat holds, never with read-then-write in application code.
- Holds with expiry separate choosing seats from paying; expiry is checked on every read and confirmation, and cleaned up by a scheduled job.
- Payment and confirmation are idempotent, and a payment that succeeds after its hold expired is resolved by re-acquiring seats or refunding automatically.
- Big on-sales are a traffic-shaping problem: a virtual waiting room admits buyers at a rate the inventory service can serve, alongside per-user limits and bot defences.
At 12:00 noon, tickets for a cricket final go on sale. The stadium holds 50,000 seats. A million people are waiting, refreshing their screens. In the first few seconds, tens of thousands of them click on the same few hundred seats behind the bowler's arm. Every one of those clicks is a race. If the system lets two people buy the same seat, two families travel across the country for a seat only one of them can sit in.
Ticket booking is the clearest example of contended, scarce inventory, and it brings together two very different problems: correctness (never sell a seat twice, never charge without a ticket) and traffic (a burst a thousand times normal, much of it from bots). This case study solves both.
What you will learn
#- How to model seats, holds and bookings.
- How to claim seats atomically, including several seats at once.
- Holds with expiry, and how expiry is enforced.
- An idempotent payment-and-confirmation flow, and the late-payment problem.
- How to survive a big on-sale with a virtual waiting room.
- Handling hot inventory, bots and failures.
1. The problem
#Build a ticketing platform for events (concerts, matches, shows). Events have either reserved seating (pick seat C14) or general admission (any of 5,000 standing tickets). Users browse events, view seat availability, select seats, hold them while they pay, and receive tickets. Popular events go on sale at a fixed time and sell out in minutes.
2. Clarify the requirements
#Question | Assumed answer | Impact |
|---|---|---|
Reserved seating, general admission, or both? | Both | Two inventory models |
How long can a user hold seats while paying? | 10 minutes | Hold expiry |
Maximum tickets per user per event? | 6 | Per-user limits |
Payment? | Through a payment provider; card details never touch our servers | Idempotent provider calls, webhooks |
Big on-sales? | Yes: up to about a million people waiting for an event of 50,000 seats | Waiting room |
Cancellations and refunds? | Allowed by event rules, before the event | Seats return to inventory |
Resale and transfers? | Out of scope | n/a |
Dynamic pricing? | Out of scope | n/a |
Functional requirements: list and search events; show seat availability; hold seats (or a quantity for general admission) for 10 minutes; pay and confirm a booking; issue tickets; cancel within rules.
Non-functional requirements
Quality | Target |
|---|---|
Correctness | A seat is never sold twice; no booking without payment; no payment without a booking or refund |
Fairness | During on-sales, buyers are served roughly in arrival order; bots are limited |
Latency | Hold responses p99 under 500 ms even during on-sales (for admitted users) |
Availability | Browsing highly available; booking may queue users rather than fail |
Consistency | Strong for seat allocation; seat maps may lag by a second or two |
3. Assumptions and scale estimation
#Normal days:
50,000 bookings/day across all events -> under 1/s on averageBrowsing: 5 million event and seat-map views/day -> about 60/s averageA big on-sale (assumption):
1,000,000 people in the first minutes; each refreshes the seat map and tries several holdsSeat-map requests: 200,000/s at the peak momentHold attempts: 50,000/s, mostly targeting a few thousand "best" seatsInventory: 50,000 seats, so at most 50,000 successful holds (plus re-holds after expiry)Conclusions: the data is tiny (tens of thousands of seat rows per event); the difficulty is contention (many writers on the same few rows) and bursts a thousand times normal traffic. Correctness depends on the inventory store; survival depends on controlling how many requests reach it.
4. API design
#GET /v1/events?city=chennai&from=2026-11-01 -> list (cacheable)GET /v1/events/{eventId}/seats -> seat map with availability (cached for 1-2 s)POST /v1/events/{eventId}/holdsIdempotency-Key: 8b1e...{ "seat_ids": ["C14", "C15"] } (or {"section": "GA-East", "quantity": 4})201 { "hold_id": "h_41", "seats": ["C14","C15"], "expires_at": "...12:10:00Z", "amount": 900000 }409 one or more seats no longer available (with the conflicting seats)422 exceeds per-user limitPOST /v1/bookingsIdempotency-Key: 2f7a...{ "hold_id": "h_41", "payment_method_token": "pm_..." }202 { "booking_id": "b_77", "status": "payment_pending" } -> becomes "confirmed" or "failed"410 hold expiredGET /v1/bookings/{bookingId} -> status and ticketsDELETE /v1/holds/{holdId} -> release earlyPOST /v1/bookings/{bookingId}/cancellation -> refund per rulesAmounts are integers in the smallest currency unit (paise). Holds and bookings take idempotency keys, because users double-click and mobile networks retry (article 20).
5. Data model
#CREATE TABLE seats ( event_id BIGINT NOT NULL, seat_id VARCHAR(10) NOT NULL, section VARCHAR(20) NOT NULL, price_paise BIGINT NOT NULL, status VARCHAR(10) NOT NULL DEFAULT 'free', -- free, held, booked hold_id BIGINT, hold_expires_at TIMESTAMPTZ, booking_id BIGINT, PRIMARY KEY (event_id, seat_id));CREATE TABLE holds ( hold_id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, user_id BIGINT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, status VARCHAR(12) NOT NULL, -- active, converted, released, expired idem_key VARCHAR(64) NOT NULL, UNIQUE (user_id, idem_key));CREATE TABLE bookings ( booking_id BIGINT PRIMARY KEY, hold_id BIGINT NOT NULL UNIQUE, -- one booking per hold user_id BIGINT NOT NULL, event_id BIGINT NOT NULL, status VARCHAR(16) NOT NULL, -- payment_pending, confirmed, failed, cancelled, refunded payment_id VARCHAR(64), amount_paise BIGINT NOT NULL);-- General admission: one counter row per section (or several buckets, see section 8).CREATE TABLE ga_inventory ( event_id BIGINT NOT NULL, section VARCHAR(20) NOT NULL, bucket INT NOT NULL, remaining INT NOT NULL CHECK (remaining >= 0), PRIMARY KEY (event_id, section, bucket));The seat row is the single source of truth for each seat. The CHECK (remaining >= 0) makes overselling general admission impossible at the database level. Partition key: event_id, so all of an event's inventory lives on one database (or one shard), where strong consistency is cheap.
6. Baseline architecture
#Clients -> Load balancer -> Booking API -> PostgreSQL (events, seats, holds, bookings) -> Payment providerThe naive hold handler reads the seats, checks they are free in application code, and updates them. Article 14 showed exactly how that fails: two requests read "free" before either writes "held". The baseline works in testing and double-books under load.
7. What breaks first?
#- Races on seats (check-then-act): double holds and double bookings.
- Multi-seat consistency: a user wants C14 and C15 together; getting only one is useless.
- Holds that never end: a user closes the app; seats stay held.
- Payment timing: the payment can take seconds or fail ambiguously, and can even succeed after the hold expired.
- On-sale bursts: 50,000 hold attempts per second on a few thousand rows, plus 200,000 seat-map reads per second.
- Bots grabbing seats faster than people.
8. Scaling the design
#Atomic, all-or-nothing seat holds
#Holds use a conditional update that both checks and claims, inside one transaction:
BEGIN;UPDATE seatsSET status = 'held', hold_id = :hold_id, hold_expires_at = now() + interval '10 minutes'WHERE event_id = :event_id AND seat_id IN ('C14', 'C15') AND (status = 'free' OR (status = 'held' AND hold_expires_at < now()));-- If the number of rows updated is not 2, ROLLBACK: someone else has one of the seats -> 409.INSERT INTO holds (hold_id, event_id, user_id, expires_at, status, idem_key)VALUES (:hold_id, :event_id, :user_id, now() + interval '10 minutes', 'active', :idem_key);COMMIT;- The
WHEREclause makes the check and the claim one atomic step per row; the row-count check makes the hold all-or-nothing across seats. - Expired holds count as free (lazy expiry), so seats come back the moment their hold lapses, even before any cleanup job runs.
- Updating several rows can deadlock with another transaction updating the same rows in a different order; PostgreSQL detects and resolves deadlocks by aborting one transaction (Explicit Locking). Updating rows in a consistent order (by
seat_id) avoids most of them; retry the rest. - PostgreSQL's default isolation level (Read Committed) is sufficient here because each row's check and update happen in one statement (Transaction Isolation); no application-level read precedes the write.
General admission uses a conditional decrement instead: UPDATE ga_inventory SET remaining = remaining - 4 WHERE event_id = ? AND section = ? AND bucket = ? AND remaining >= 4.
Expiry and cleanup
#- Every read of availability, and every confirmation, treats
heldwithhold_expires_at < now()as free. - A scheduled job (article 37) runs every minute: it marks expired holds
expired, resets their seats tofree(with a conditional update that only touches seats still held by that hold), and returns general-admission quantities to their buckets.
Payment and confirmation
#1. POST /v1/bookings (idempotency key): in one transaction, - check the hold is active, belongs to the user, and has not expired; - insert booking (status payment_pending; hold_id is UNIQUE, so a second booking for the same hold is impossible); - extend the hold slightly (for example 2 minutes) to cover payment processing.2. Call the payment provider with an idempotency key derived from the booking id.3. On success (synchronously, or later via a verified webhook): in one transaction, - UPDATE seats SET status = 'booked', booking_id = :b WHERE hold_id = :h AND status = 'held'; - booking -> confirmed; hold -> converted; write an outbox event BookingConfirmed.4. Tickets are issued and the user notified asynchronously (article 33).5. On failure: booking -> failed; the hold may be released or kept until expiry so the user can retry.The idempotency key in step 2 matters because payment calls time out ambiguously; providers such as Stripe document idempotent requests so that a retried call returns the original result instead of charging again (Idempotent requests).
When payment succeeds after the hold expired
#The dangerous case: the provider is slow, the hold expires, someone else takes the seats, and then the payment succeeds. Step 3's conditional update changes zero rows. Now we have money and no seats. Handle it as a saga with compensation (article 21; Microsoft's Saga guidance describes compensating transactions for steps that cannot complete (Saga pattern)):
- Try to re-acquire the same seats (they may still be free) with the hold update.
- If not, offer equivalent seats if the user opted in, or
- Refund automatically (idempotently, keyed by booking), mark the booking
refunded, and notify the user honestly.
Extending the hold at payment start (step 1) makes this case rare; designing for it makes it harmless.
The on-sale: a virtual waiting room
#No amount of database tuning lets 50,000 hold attempts per second on a few thousand rows be pleasant. Shape the traffic instead:
Users --> CDN (static event page, cached) --> Waiting room service each user gets a signed queue token with a position (arrival order, randomised slightly within the first seconds before opening to reduce refresh racing) --> admitted at a controlled rate (for example 2,000 users/minute) --> Booking API accepts requests only with a valid "admitted" token- The waiting room is a queue in front of the inventory, a form of queue-based load leveling (Queue-Based Load Leveling): admitted users get a fast, reliable experience; waiting users see their position.
- The admission rate is set from what the inventory service handles comfortably and from how fast seats actually sell, and adjusted live.
- Waiting-room state is simple and highly scalable (a counter and signed tokens), and can be served largely from the edge.
Hot inventory and seat maps
#- Partition by event: a big event's inventory lives on its own database primary (or shard), so it does not affect other events, with a synchronous standby for failover.
- Seat maps are read far more often than they change. Serve them from a cache refreshed every second or two (or pushed over a real-time channel to admitted users). They are advisory: the hold is the authority, and a 409 response tells the client which seats were taken so it can refresh.
- General admission buckets: split each section's quantity across several bucket rows (for example 10), and pick a bucket at random; this removes the single hot row (article 14). A hold that finds its bucket empty tries others.
- Best-available allocation: offering "4 best available seats in Section B" and choosing seats server-side (from an in-memory index of free seats per section, verified by the conditional update) spreads contention better than letting everyone pick the same seats.
- Very large on-sales can route all hold requests for one event through a single-writer allocator process (holding the event's free-seat index in memory, persisting each allocation), which serialises allocation without database lock contention. That trades simplicity for throughput; use it only if measurements show the database is the bottleneck.
The evolved architecture
#REQUEST PATH (on-sale)Users -> CDN (event page) -> Waiting room (signed tokens, admission rate) -> Booking API (admitted only; per-user limits; bot checks) -> Inventory DB for this event (primary + sync standby): atomic holds and confirmations -> Seat-map cache (refreshed every 1-2 s) -> Payment provider (idempotent), webhooks -> Booking APIASYNC PATHHold expiry job (scheduler, every minute) -> releases expired holdsOutbox -> BookingConfirmed -> ticket issuing, notifications, analyticsReconciliation job -> payments without bookings -> re-acquire or refund9. Failure scenarios and reliability
#Failure | Effect | Response |
|---|---|---|
User double-clicks "Book" | Two booking requests | Idempotency key returns the same booking; |
Payment call times out | Unknown outcome | Booking stays |
Payment succeeds after hold expiry | Paid, seats gone | Saga: re-acquire or refund automatically |
Hold expiry job down | Expired holds stay | Lazy expiry treats them as free anyway; job catches up |
Inventory DB primary fails mid on-sale | Holds fail for seconds | Synchronous standby, automatic failover; waiting room pauses admission briefly |
Waiting room fails | Uncontrolled traffic would reach booking | Fail closed for on-sale events: pause sales with a clear message rather than collapse |
Seat map stale | User picks a taken seat | Hold returns 409 with conflicts; UI refreshes |
Webhook lost | Booking stuck pending | Periodic reconciliation with the provider |
10. Consistency
#- Seat allocation: strongly consistent, on one primary per event, enforced by conditional updates and constraints. The invariant "booked seats never exceed capacity, and each seat has at most one booking" holds by construction; a background check can verify it continuously.
- Seat maps: eventually consistent (seconds).
- Payment and booking: eventually consistent through the saga, with every intermediate state visible (
payment_pending) rather than hidden. - Read-your-writes: a user's own holds and bookings are read from the primary.
11. Security
#- Bots and scalpers are the main abuse: OWASP lists unrestricted access to sensitive business flows (such as buying all tickets) as an API risk (OWASP API Security Top 10 2023). Defences: per-account and per-payment-method ticket limits, CAPTCHA or proof-of-work at the waiting room, device and behaviour signals, verified accounts for high-demand events, and named tickets checked at entry.
- Signed waiting-room tokens with short validity, bound to the user, so positions cannot be forged or shared.
- Payments through the provider's tokens; no card data stored (article 28).
- Tickets as signed, unguessable codes (for example QR codes containing a signed booking reference), validated at the gate, with protection against reuse.
- Authorisation: users can see and cancel only their own holds and bookings.
12. Observability
#Signal | Why |
|---|---|
Hold success, conflict (409) and error rates | Contention and correctness |
Hold latency p99 and inventory DB lock waits | Hot-row pressure |
Waiting room size, admission rate, abandonment | On-sale health |
Bookings in | Payment problems |
Late payments and automatic refunds | Saga health |
Invariant check: booked seats versus capacity | Must always hold; page immediately if violated |
Bot-detection actions and per-account limit hits | Abuse |
13. Trade-offs
#Decision | Chosen | Alternative | Why |
|---|---|---|---|
Seat allocation | Conditional updates on one primary per event | Distributed locks, eventual consistency | Strong guarantees with the database as referee |
Hold expiry | Lazy expiry plus a cleanup job | Exact timers per hold | Correct even if cleanup is late |
Payment | Asynchronous with idempotent provider calls and a saga | Synchronous all-in-one | Handles timeouts and late results without losing money or seats |
On-sale | Virtual waiting room | Scale the database for the full burst | Contention cannot be scaled away; shaping traffic is fairer and cheaper |
Seat maps | Short-lived cache, advisory | Strongly consistent reads | 200,000 reads per second; holds are the authority |
General admission | Bucketed counters | Single counter row | Removes the hot row |
14. The final interview answer
#"The non-negotiable requirement is that a seat is never sold twice, under on-sale bursts of about 50,000 hold attempts a second for 50,000 seats.
Each event's inventory lives on one primary database with a synchronous standby. A hold is a single transaction with a conditional update that claims every requested seat only if it is free or its hold has expired; if fewer rows change than requested, it rolls back and returns 409. General admission uses bucketed counters with conditional decrements and a non-negative check. Holds last 10 minutes; expiry is enforced on every read and cleaned up by a scheduled job.
Booking is idempotent: one booking per hold, then an idempotent call to the payment provider. On success we mark the seats booked with a conditional update on the hold; if the hold expired and the seats were taken, a saga re-acquires seats or refunds automatically. Big on-sales go through a virtual waiting room that admits users at a rate the inventory can serve, with per-account limits and bot defences. Seat maps are cached for a second or two and are advisory; the hold is the authority."
Exercises
#Exercise 1
The multi-seat hold
#A user selects seats C14, C15 and C16. Between their view of the seat map and their click, someone else holds C15. Walk through exactly what the hold transaction does and what the user sees. How would you make the UI helpful?
Exercise 2: Late payment
#A user's hold expires at 12:10:00. Their payment is submitted at 12:09:50, the provider is slow, and success arrives at 12:10:40. Meanwhile another user held the same seats at 12:10:05. Describe each state change in the database and what each user experiences.
Exercise 3: Admission rate
#An event has 50,000 seats. On average an admitted user takes 4 minutes from admission to completed booking, buys 2.5 seats, and 30% of admitted users leave without buying. The inventory service handles 2,000 holds per second comfortably. What admission rate would sell out in about 30 minutes, and is the inventory service at risk?
Exercise 4: Seated or general admission?
#Explain why general admission is easier to scale than reserved seating, and propose how to handle a 100,000-person standing-only festival on-sale.
Show solutionHide solution
The transaction runs UPDATE seats ... WHERE seat_id IN ('C14','C15','C16') AND (free or expired hold). C14 and C16 match; C15 does not (it is held by someone else). The update reports 2 rows, not 3, so the application rolls back: C14 and C16 are not held either (all-or-nothing). The API returns 409 with {"unavailable": ["C15"]}.
Helpful UI: refresh the seat map, highlight that C15 was just taken, and suggest the nearest available block of three seats together (computed from the free-seat data). With best-available allocation ("3 seats together in this row or nearby"), the server can find and claim an alternative block in one step.
Exercise 2
Show solutionHide solution
- 12:09:50: booking
b1created aspayment_pending; the hold is extended by 2 minutes, to 12:12:00 (if extension is implemented). In that case the hold is still valid at 12:10:05, so the second user's hold attempt fails with409for those seats, and at 12:10:40 the payment succeeds and the seats are markedbooked. Everyone gets a consistent outcome. - Without extension: the hold expires at 12:10:00. At 12:10:05 the second user's conditional update treats the expired hold as free and claims the seats (
hold_id= h2). At 12:10:40 payment forb1succeeds; the confirmation updateWHERE hold_id = h1 AND status = 'held'changes 0 rows. The saga starts: it tries to re-acquire the seats (taken by h2: fails), tries equivalent seats if the user agreed, otherwise issues an automatic refund keyed byb1, setsb1torefunded, and notifies the first user. The second user continues normally.
The lesson: extend holds during payment to make the race rare, and design the compensation so it is harmless when it happens.
Exercise 3
Show solutionHide solution
Buyers needed: 50,000 seats / 2.5 = 20,000 purchasing users. With 30% leaving, admitted users needed: 20,000 / 0.7 = about 28,600. To sell out in about 30 minutes, admit roughly 28,600 / 30 = about 950 users per minute (allowing for the 4-minute average purchase time, the last admissions should come a few minutes before the target, so slightly higher, around 1,000 to 1,100 per minute).
Inventory load: about 1,000 users per minute, each making a few hold attempts (say 3, including conflicts): about 3,000 attempts per minute, 50 per second, far below 2,000 per second. The inventory service is safe; the waiting room is doing its job. The admission rate is limited by fairness and seat availability, not by the database, so it can even be raised if seats sell slowly.
Exercise 4
Show solutionHide solution
General admission has no seat identity: any ticket is as good as another, so the inventory is a count, and holds are decrements. Contention is on a counter, which can be split into many buckets (or pre-allocated to servers), and there is no "everyone wants the same seat" problem or multi-seat adjacency.
For a 100,000-person standing festival: bucketed counters (for example 50 buckets per ticket type) with conditional decrements; a waiting room with an admission rate set from purchase throughput; per-account limits; idempotent payment as before. Optionally, a token pre-allocation: each booking server takes blocks of tickets from the counters (for example 100 at a time) and sells from memory, persisting each sale, which reduces database round trips; unsold blocks are returned periodically. A final reconciliation check confirms total sold never exceeds capacity.
Where to go next
#- Next: Design a Payment System: Idempotency, Ledgers and Reconciliation, which goes inside the payment step used here.
- Later: the flash sale in Design an E-Commerce System reuses the waiting room and bucketed inventory.
Sources & further reading
- Technology
- Software Engineering
- #case study
- #consistency
- #distributed systems
- #roadmap
- #scalability
- #system design
- #system design interview
- #transactions
Connected lessons
Linked from 1 guide
Other pages in the library that point here.
Keep reading
More in Technology →Guide · Technology
Start Here: Your Roadmap from Zero to Job-Ready DSA
Guide · Technology
SQL and PostgreSQL Fundamentals for Java Developers
Guide · Career
A LinkedIn Profile That Gets Recruiter Messages

