Designing a Ticket Booking System
Design a ticket booking platform that prevents double-booked seats, holds inventory during payment and survives on-sale traffic spikes.
Booking systems sell a finite set of uniquely identified items, such as seats, to a crowd that arrives all at once. The core problem is simple to state: never sell the same seat twice, while thousands of users compete for it in the same second.
Requirements
Functional
- Browse events and view a live seat map.
- Hold selected seats for a short window, pay, receive tickets.
- Cancel and release seats; support general admission and reserved seating.
Non-functional
- Zero double bookings.
- Seat map reads at 99.9% availability, p99 under 200 ms.
- Absorb a 100x spike when a popular on-sale opens.
- Holds expire automatically after 8 minutes.
Capacity Estimation
A stadium has 60,000 seats. A hit event draws 2M concurrent users in a waiting room, but only about 10,000 are admitted at a time. Seat map polling at one request per 3 seconds from 10,000 admitted users is about 3,300 reads per second per event, easily served from cache. Write load is hold attempts: perhaps 2,000 per second at the start, dropping fast. Total bookings are 60,000 rows, trivially small. Scale is about contention, not volume, so the design optimizes hot-row behavior.
High-Level Design
Users -> CDN -> Virtual Waiting Room (admission tokens)
-> API Gateway
|-> Catalog Service (events, venues; cached)
|-> Seat Service (holds, state) -> SQL primary (per event partition)
| -> Redis (seat map cache, TTL holds)
|-> Booking Service -> Payment Service
'-> Ticket Service -> Notification
Expiry Worker: sweeps expired holds back to available
Data Model
events(event_id, venue_id, starts_at, on_sale_at)seats(event_id, seat_id, section, row, number, price_tier, state, hold_id, hold_expires_at, version)holds(hold_id, event_id, user_id, seat_ids, expires_at, state)bookings(booking_id, hold_id UNIQUE, user_id, payment_ref, state)tickets(ticket_id, booking_id, seat_id, barcode)
Seat state is one of available, held, sold. Partition the seats table by event_id so one event's contention does not affect others.
Deep Dives
Preventing double booking
The database is the source of truth. A hold is a single conditional update: set state to held, hold_id, and expiry where state is available, or where state is held and expiry is in the past. If the affected row count equals the number of requested seats, the hold succeeded; otherwise roll back the transaction. For multiple seats, update them in sorted seat_id order to avoid deadlocks. Redis may accelerate the seat map but is never trusted for the final decision.
An alternative is pessimistic locking with select for update, which is simpler to reason about but holds row locks longer. Optimistic conditional updates keep transactions short, which matters under heavy contention.
Holds and expiry
Holds carry an expiry timestamp checked on every read and write, so correctness does not depend on the sweeper. A background worker releases expired holds for cache freshness and notifies the seat map via pub/sub. Payment must verify the hold is still valid at capture time: confirm by updating the seats from held to sold where hold_id matches and expiry is in the future. If the payment finishes after expiry and the seats are gone, refund automatically.
Idempotency
Booking requests carry a key stored with a unique index. Double-clicks and client retries return the same booking. The hold_id unique constraint on bookings prevents two bookings from one hold.
Fairness and load
A waiting room assigns random positions to everyone who arrives in the first few minutes, which is fairer than first-come-first-served for a race against bots. Admission tokens are signed and short-lived, and the admitted rate is tuned to what the seat service sustains. Add per-user and per-device limits plus bot detection at the edge.
Seat map consistency
The map shown to users can be stale by a second or two. Serve it from cache with short TTL and push deltas over WebSocket. Staleness is acceptable because the hold step is authoritative; the user simply sees that the seat was just taken.
Failure Modes and Trade-offs
- Payment succeeds but booking commit fails: the booking and payment reference are reconciled by a recovery job that either completes the booking or refunds.
- Primary database failover: use synchronous replication for the seat partition so an acknowledged hold is never lost.
- Cache outage: the map falls back to database reads protected by request coalescing.
- Hold duration: longer holds reduce checkout failures but let abandoned carts block inventory; 5 to 10 minutes is typical.
- Best-available versus user-chosen seats: automatic assignment reduces contention on single seats and improves conversion, at the cost of user control.
Key takeaways
- The seat row in a strongly consistent database is the single source of truth; caches only accelerate reads.
- Use conditional updates with expiry timestamps so holds are correct even if the sweeper lags.
- Update multiple seats in a deterministic order to avoid deadlocks.
- A waiting room converts a thundering herd into a controlled admission rate.
- Idempotency keys and unique constraints make retries harmless.
- Partition by event so hot events cannot degrade the rest of the platform.