Skip to content
SparkTime, home
System Design From Zero to Interview-Ready
Lessons
1 · System Design Foundations
2 · APIs and Communication
3 · Capacity Estimation
4 · Core Building Blocks
5 · Databases
6 · Distributed Systems
7 · Reliability and Operations
8 · Architecture and Security
9 · Interview Method and Foundation Case Studies
10 · Real-World Case Studies
11 · Large-Scale Case Studies
12 · Interview Readiness
Lesson 38 of 45

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.

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:

Code
50,000 bookings/day across all events -> under 1/s on averageBrowsing: 5 million event and seat-map views/day -> about 60/s average

A big on-sale (assumption):

Code
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

#
Code
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 rules

Amounts 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

#
SQL
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

#
Code
Clients -> Load balancer -> Booking API -> PostgreSQL (events, seats, holds, bookings)                                        -> Payment provider

The 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?

#
  1. Races on seats (check-then-act): double holds and double bookings.
  2. Multi-seat consistency: a user wants C14 and C15 together; getting only one is useless.
  3. Holds that never end: a user closes the app; seats stay held.
  4. Payment timing: the payment can take seconds or fail ambiguously, and can even succeed after the hold expired.
  5. On-sale bursts: 50,000 hold attempts per second on a few thousand rows, plus 200,000 seat-map reads per second.
  6. 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:

SQL
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 WHERE clause 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 held with hold_expires_at < now() as free.
  • A scheduled job (article 37) runs every minute: it marks expired holds expired, resets their seats to free (with a conditional update that only touches seats still held by that hold), and returns general-admission quantities to their buckets.

Payment and confirmation

#
Code
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)):

  1. Try to re-acquire the same seats (they may still be free) with the hold update.
  2. If not, offer equivalent seats if the user opted in, or
  3. 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:

Code
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

#
Code
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 refund

9. Failure scenarios and reliability

#

Failure

Effect

Response

User double-clicks "Book"

Two booking requests

Idempotency key returns the same booking; hold_id unique

Payment call times out

Unknown outcome

Booking stays payment_pending; retry with the same provider key; webhook or status query resolves it

Payment succeeds after hold expiry

Paid, seats gone

Saga: re-acquire or refund automatically

Hold expiry job down

Expired holds stay held in the table

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_pending by age

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 b1 created as payment_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 with 409 for those seats, and at 12:10:40 the payment succeeds and the seats are marked booked. 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 for b1 succeeds; the confirmation update WHERE 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 by b1, sets b1 to refunded, 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

#

Sources & further reading

  1. postgresql.org
  2. postgresql.org
  3. api-security.owasp.org
  4. docs.stripe.com
  5. learn.microsoft.com
  6. learn.microsoft.com
  • 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