At a glance#
| Number | Value | What it drives |
|---|---|---|
| On-sale crowd · tickets | 13 million · ~1.5 lakh | ~1 buyer in 260; a waiting room, not a bigger database |
| Ticket limit | 4 per person | checked at hold time, across tabs and retries |
| Completed checkouts in an on-sale | ~19 / s | admit on completions, ~3,300 in checkout at once |
| Naive queue polling | 1.3 million req / s | status from the edge, polling slows with position |
| Cinema peak day (design target) | 3 million tickets · 1.2 million bookings | ~40 bookings/s, ~200/s when a blockbuster opens |
| Seat map opens | ~600 / s · ~3,000 / s at a blockbuster | layout on the CDN, state pushed as deltas |
| Every open seat map | ~22 MB at 2 bits a seat | space is not the problem; ownership is |
| Hold time | 8 min cinema · 5 min on-sale | expiry stored on the seat |
Architecture#
DiagramSeat booking on one page
%% title: Seat booking on one page
flowchart TD
U@{ icon: "sd:users", label: "Customers<br/>app + web" } --> CDN@{ icon: "sd:cdn", label: "CDN<br/>seat layouts, posters" }
U ==>|"on-sales"| WR["Waiting room<br/>lottery at the open · signed tokens"]:::queue
U ==> GW@{ icon: "sd:gateway", label: "API gateway<br/>auth · rate limits · bot checks" }
WR ==>|"admitted only"| GW
GW --> MAP@{ icon: "sd:cache", label: "Seat map cache<br/>2-bit bitmap per show" }
GW ==> HOLD["Hold service<br/>all or nothing · 8-min expiry<br/>one writer per show"]:::hot
GW ==> BK["Booking saga<br/>hold → pay → confirm → ticket"]:::core
HOLD ==> SEAT@{ icon: "sd:database", label: "Seat store<br/>our inventory, sharded by show" }
HOLD ==> ADP["Chain adapters<br/>hold id as ref · breaker per chain"]:::service
ADP --> POS@{ icon: "sd:server", label: "Chain ticketing systems<br/>box office, own apps" }
BK --> PAY@{ icon: "sd:card", label: "Payment gateways" }
BK -.->|"sell by hold id"| HOLD
SEAT -.-> LOG@{ icon: "sd:stream", label: "Seat change stream<br/>versioned deltas" }
LOG --> MAP
class U client
class CDN,GW edge
class MAP cache
class SEAT store
class LOG queue
class POS,PAY external- Read plane (CDN, seat map cache) may be a second or two stale; the hold service is the only thing that decides who gets a seat.
- Two kinds of owner behind one interface: our seat store, and cinema chains' own systems, reached through adapters.
- The waiting room exists only for on-sales, on capacity cinema traffic doesn't share.
DiagramThe on-sale front door
%% title: The on-sale front door
flowchart TD
U@{ icon: "sd:users", label: "13 million people" } ==> EDGE@{ icon: "sd:cdn", label: "Edge<br/>static page · cached status" }
EDGE ==> BOT@{ icon: "sd:shield", label: "Bot checks<br/>device · account age · velocity" }
BOT ==> LOT["12:00:00 lottery for everyone waiting<br/>then arrival order"]:::queue
LOT ==> ADM{"Room in checkout<br/>and tickets left after holds?"}
ADM ==>|"yes"| CK["Checkout<br/>~3,000 at once · 5-min holds"]:::hot
ADM -->|"tickets gone"| SO(["Sold out · said once, to everyone"]):::bad
CK -.->|"finished or expired · free a slot"| ADM
class U client
class EDGE,BOT edgeKey flows#
DiagramFour seats in row H, on our inventory
%% title: Four seats in row H, on our inventory sequenceDiagram autonumber participant A as App participant R as Read plane participant H as Hold service participant B as Booking saga participant P as Payment A->>R: Open seat map R-->>A: Layout from CDN + bitmap v418 A->>H: Hold H5-H8 (key k1) H-->>A: Held, all 4 · expires in 8 min (hold h1) A->>B: Checkout h1 (key k1) B->>P: Create payment (key k1) P-->>B: Success B->>H: Sell h1 H-->>B: Sold · v419 B-->>A: Ticket (QR) Note over R: v419 pushed to every viewer · H5-H8 turn grey
DiagramA chain's system doesn't answer
%% title: A chain's system doesn't answer
sequenceDiagram
autonumber
participant H as Hold service
participant D as Chain adapter
participant C as Chain system
H->>D: Hold seats (ref h1, ttl 8 min)
D->>C: POST hold · ref h1
C--xD: no reply in 3 s
Note over D: unknown · never send a second hold with a new ref
D->>C: Status of h1
alt hold exists
C-->>D: Held until 19:03
D-->>H: Held · show the customer an earlier expiry
else no such hold
C-->>D: Not found
D-->>H: Not held · customer picks again
endDiagramPaid after the hold expired
%% title: Paid after the hold expired
sequenceDiagram
autonumber
participant B as Booking saga
participant H as Hold service
participant P as Payment
P-->>B: Success after 9 min
B->>H: Sell h1
H-->>B: Lost · H7 now has another hold
B->>H: Hold the same seats again
alt seats still free
H-->>B: Held · then sold
else taken
H-->>B: Taken
B->>P: Refund (keyed on the booking)
endData model#
DiagramCore entities
%% title: Core entities
classDiagram
class Show {
+showId : string · shard key
+owner : OURS or CHAIN
+layoutId : string · on the CDN
+version : int · bumped on every change
}
class Seat {
+seatId : string · H7
+state : FREE / HELD / SOLD
+holdId : string
+expiresAt : epoch ms
+bookingId : string
}
class Hold {
+holdId : uuid · partner ref too
+idemKey : string
+seats : list of seat ids
+expiresAt : epoch ms
}
class Booking {
+bookingId : string
+state : BookingState
+amount : paise
+partnerRef : string
}
class QueueToken {
+saleId : string
+accountId : string
+position : float
+signature : hmac
}
Show "1" --> "many" Seat : has
Hold "1" --> "many" Seat : holds
Booking "1" --> "1" Hold : sells
QueueToken "many" --> "1" Show : waits for- Seat state is partitioned by show: every hold, sale and release for a show runs on one shard.
- A show's state is ~63 bytes; a 60,000-seat stadium is ~15 KB; bookings ~1.5 KB each, ~1.8 GB a day.
Lifecycle#
DiagramLife of one seat
%% title: Life of one seat stateDiagram-v2 [*] --> FREE FREE --> HELD: hold accepted, all seats or none HELD --> SOLD: payment confirmed, same hold id HELD --> FREE: released or expired SOLD --> USED: scanned at the venue SOLD --> FREE: cancelled where allowed USED --> [*] class SOLD,USED ok class HELD queue
Decisions#
| Decision | Chose | Over | Because |
|---|---|---|---|
| Multi-seat holds | One atomic check-and-set per show | Seat-by-seat locks | no deadlocks, no partial sets |
| Abandoned holds | Expiry stored on the seat | Sweeper or client release | correct even if the sweeper runs late |
| Selling | By hold id | By customer id | a late payment can't sell re-held seats |
| Chain-owned seats | Chain's system decides, via adapter | Our cached copy | box office and own apps sell the same seats |
| Partner timeout | Unknown, then status call | Retry or assume failure | no double holds, no stuck seats |
| On-sale order | Lottery at the open, then arrival order | First come, first served | racing the clock is worthless to scripts |
| Admission | On completed or expired checkouts | Fixed rate | follows what checkout really absorbs |
When it breaks#
| Failure | What users see | Contain it with |
|---|---|---|
| Two families tap overlapping seats | one gets all 4, the other picks again | atomic all-or-nothing hold |
| Payment after the hold expired | seats if still free, else auto refund | sell by hold id, re-hold, refund |
| A chain's system is down | that chain's shows paused | bulkhead + breaker per chain |
| 13 million wait at noon | a queue page, not an error | edge-served status, slower polling far back |
| Tickets resold by account farms | fewer touts, ID checks for the biggest events | per-account limits, bot checks, refreshed QR codes |
Say it in the interview#
- "Who owns the seat?" Ours: one writer per show. A chain's: their system decides, we ask.
- All or nothing: a family wants four seats together, so one atomic step holds all four or none.
- A hold is a state, not a lock: the expiry lives on the seat, so walking away costs nothing.
- A timeout is unknown: ask for the hold's status with our hold id; never re-hold with a new one.
- Waiting is the cost: ~19 checkouts a second, but naive polling would be 1.3 million requests a second.
- Fair to humans: a lottery at 12:00:00 and one position per account make scripts pointless.
- Say "sold out" once: stop admitting when tickets left can't serve the next person, wait out holds, then tell everyone.