System design interview · backend

Design a ticket booking system

"Ticketmaster for a hot show: 100,000 fans rush for 10,000 seats the second sales open. No double-booked seats, no overselling, and the site can't fall over."

⚡ The takeaway first

Seats are inventory — the read and the reserve must be one atomic operation. Never do "check seats left, then book." Instead: hand out queue tokens for fairness (first-come, first-served), hold a seat with a TTL lock while the user pays, and decrement the seat counter with a single atomic operation. If two requests arrive for the last seat, exactly one wins — decided by the datastore, not by luck.

📋 Requirements

Functional

Non-functional

🚫 Common misconception

"Showing '10 seats left' means 10 people can each grab one." No — that number was true when the page loaded, and 40,000 people loaded the same page. Every one of them sees "10 left," and without coordination all 40,000 will try to take them. Availability displayed is not availability reserved. The only number that matters is the one decremented atomically at the moment of booking.

🧮 Back-of-the-envelope math

AssumptionValue
Fans in first 60s100,000
Arrival rate100k ÷ 60 ≈ 1,700 requests/sec sustained burst
Seats10,000
Hold TTL (time to pay)10 minutes
Payment completion rate~70% — so ~3,000 seats get released back
Queue drain10k seats ÷ 1.7k users/sec ≈ 6 seconds to assign every seat

The burst is short and brutal: the system needs to serialize 1.7k competing writers/sec for about a minute. That's trivial for a single atomic counter — and impossible to get right with read-then-write.

Go deeper: why 10-minute holds are a DoS vector

A malicious user could hold 4 seats × 10 min repeatedly, keeping inventory locked without paying. Defenses: per-user hold limits (e.g. 4 seats), holds that don't renew, and requiring a verified account + payment method on file before entering the queue. The TTL isn't just UX — it's inventory protection.

🏗️ Architecture

The centerpiece: a queue in front, an atomic counter at the core.

flowchart TD
    A[100k fans] --> B[CDN + static sale page]
    B --> C[Queue service
hands out numbered tokens] C --> D[Booking API
validates token order] D --> E[(Seat inventory
atomic decrement)] D --> F[Payment service] E --> G[(Orders DB)] F --> G H[Hold sweeper
releases expired TTL holds] --> E I[Waitlist service] --> D

Think of it like a bakery on cronut day: the queue service hands out numbered tickets at the door (fairness), the counter only serves ticket #1, then #2 (order), and the tray of cronuts is counted by one person with a clicker — atomic decrement. Nobody reaches behind the counter to grab one themselves.

🔍 Component deep-dives

The booking — one atomic step, not two

sequenceDiagram
    participant U as Fan (token #42)
    participant B as Booking API
    participant S as Seat inventory (Redis)
    participant P as Payment
    U->>B: POST /events/e9/book {seat: A3, token: 42}
    B->>B: token #42 is now serving? yes
    B->>S: EVAL reserve.lua (check + decrement + TTL hold)
    Note over S: Lua runs atomically:
if seats_left > 0 then
seats_left-- ; hold seat 10 min S-->>B: OK seat A3 held B->>P: charge card (async, 10 min window) P-->>B: paid B->>S: confirm hold → booked B-->>U: 200 ticket confirmed

The Lua script is the hero: check-and-decrement happens inside Redis as one indivisible step. Two requests for the last seat arrive — Redis runs the script twice, the second sees zero left. No lock service, no distributed transaction.

The queue — fairness under burst

sequenceDiagram
    participant U as Fan
    participant Q as Queue service
    participant B as Booking API
    U->>Q: GET /events/e9/queue-token
    Q-->>U: {token: 84,213, ahead: 74,212}
    Note over U: browser polls position,
shows "you are #74,212" Q->>B: admit next 500 tokens B-->>U: your turn — 2 min to book

The queue absorbs the 100k burst so the booking API only ever serves a controlled trickle. Tokens are signed (can't be forged), and position polling is cheap and cacheable.

🔌 API + data model

API

GET  /v1/events/{id}/queue-token     → { token, position, etaSec }
GET  /v1/events/{id}/seats           → seat map (cached 5s, labeled "as of")
POST /v1/events/{id}/book            → { seatId, token } → { holdId, expiresAt }
POST /v1/holds/{holdId}/confirm      → { ticketId }   (after payment)
POST /v1/holds/{holdId}/release      → 204            (user cancels / TTL)

Data model

⚖️ Trade-offs

DecisionOption AOption BPick
Seat selectionUser picks exact seatSystem assigns best availableB for hot sales (one counter, no contention on seat A3); A only for low-demand shows
LockingPessimistic lock per seatAtomic decrement + TTL holdB — no lock manager to fail, TTL auto-heals abandoned carts
QueueVirtual waiting roomLet everyone hammer the APIA — the queue is the load shedding; without it the burst hits the DB directly
ConsistencyStrong everywhereEventual for seat map displaySplit: strong at booking (Redis+DB constraint), eventual for the displayed map (5s cache, labeled "as of")

🔥 Failure modes

🛠️ What I'd actually build

Cloudflare Waiting Room (or a small Redis-backed token service) for the queue — don't hand-roll fairness. Booking API in Go, inventory in Redis with the reserve Lua script, orders in Postgres with the unique booked-seat constraint as the final backstop. Payments via Stripe with idempotency keys end to end. Seat map served from a 5-second CDN cache stamped "as of HH:MM:SS" so nobody mistakes it for a reservation. A cron sweeper releases expired holds every 30s and nudges the waitlist. Boring parts, sharp edges.

🎤 Interview tips

Go deeper: assigned seats vs general admission

General admission (just a count) is the easy mode: one atomic counter, no seat map. Assigned seating adds per-seat contention — hot seats (front row) get hammered. The pro move: partition the counter per section (orchestra_left, balcony...) so contention spreads, and let users pick a section rather than an exact seat during the burst.

🎮 Interactive widget: 100 fans vs 10 seats

100 fans (dots) race for 10 seats. First run it without locking — every fan reads the seat map once, then "books" what looked free. Then reset and run it with queue tokens + atomic decrement.

seats sold: 0/10 double-booked: 0 waitlisted: 0

💡 The chaos mode simulates the classic bug: if (seat.free) seat.book(user) executed by 100 clients against a stale read. The clean mode simulates one Lua script per request — check, decrement, and hold as a single atomic step, served in queue-token order.

v2026.10.03-01