Design a cinema ticket booking service
Open in the interactive app: practice, quiz, mock interview →In one minute
- The core invariant is "one seat, one show, at most one paid booking". Enforce it with a conditional write at a single source of truth per show, not with a cache or a distributed lock.
- Split purchase into hold and confirm. Payment takes minutes and lives in another company's system, so you cannot keep a database transaction open across it. A hold is a row state with an expiry time.
- Expiry must be lazy: a hold is dead the instant
now >= expires_at, whether or not a cleanup job has run. Every read and every write evaluates that predicate. - Partition inventory by
show_id. All contention for a show is local to that show, so a hold for four seats is a single-shard transaction. - Blockbuster openings are a hot-key problem. A virtual waiting room in front of the hold API protects the database, and seat maps are served from a short-lived cache.
Requirements
Functional:
- Browse movies by city, then theaters and showtimes for a movie and date.
- View a seat map for a show with live availability.
- Select 1 to 10 seats and hold them for a fixed window (8 minutes here).
- Pay and receive a confirmed booking with a ticket code. Cancel within policy and get refunded.
- Expired or abandoned holds release seats automatically.
Non-functional:
- No double booking, ever. This is the correctness bar, and it outranks availability for the hold and confirm paths.
- Seat map reads p99 under 200 ms; hold p99 under 300 ms.
- Browse and seat-map reads stay up during a database failover, even if slightly stale.
- Survive opening-night spikes of 50 to 100 times normal traffic for a single show.
Out of scope: dynamic pricing, recommendations, food and beverage add-ons, the theater's own point-of-sale integration (we assume a sync API exists), and building a payment processor (we call one).
Back-of-envelope
Planning numbers for a national aggregator, not measurements.
- Screens: 10,000. Shows per screen per day: 5. Shows per day: 50,000. Average seats per screen: 200. Seat-show inventory: 50,000 × 200 = 10M rows created per day.
- DAU: 5M. Each opens about 8 seat maps: 40M seat-map reads per day, or 40M / 86,400 ≈ 460 per second on average. Evening peak is about 5x: ~2,300/s.
- Bookings: 1M orders per day averaging 2.5 seats. Average order rate: 1M / 86,400 ≈ 12/s. Hold attempts run about 3x orders: ~35/s average, ~200/s at peak.
- Opening night for a franchise film: say 1M people arrive within 10 minutes for 2,000 shows that open together. That is 1,700 arrivals per second, with the hottest shows seeing thousands of hold attempts per second against 200 rows. This, not the average, drives the design.
- Storage: a
show_seatsrow is about 60 bytes with overhead, so 10M × 60 B = 600 MB/day. A booking with its seat rows and payment metadata is about 1 KB, so 1 GB/day. Together roughly 1.6 GB/day, and 1.6 GB × 365 × 5 ≈ 2.9 TB over five years before indexes. Past shows are archived, so the hot set (next 14 days) is about 8.4 GB. - Bandwidth: a compressed seat map is about 6 KB. At 2,300/s that is ~14 MB/s; on opening night at perhaps 30,000/s it is ~180 MB/s, so the map is cached and clients poll a small version number first.
API
GET /v1/cities/{city}/movies?date=2026-10-16
GET /v1/movies/{movieId}/shows?city=blr&date=2026-10-16
-> [{showId, theaterId, screen, startsAt, priceBands}]
GET /v1/shows/{showId}/seats
-> {showId, version: 4182, seats: [{id:"F7", band:"gold", state:"available|held|sold"}]}
POST /v1/shows/{showId}/holds Idempotency-Key: <uuid>
{seatIds:["F7","F8"]}
-> 201 {holdId:"h_9x2", expiresAt:"2026-10-16T19:08:00Z", amount: 640}
-> 409 {unavailable:["F8"]}
DELETE /v1/holds/{holdId} -> 204
POST /v1/holds/{holdId}/confirm Idempotency-Key: <uuid>
{paymentIntentId:"pi_..."}
-> 200 {bookingId:"b_71", ticketCode:"QX7-..."}
-> 410 {reason:"hold_expired", refund:"initiated"}
GET /v1/users/me/bookings?cursor=...The hold call takes an idempotency key because mobile clients retry on flaky networks; a retry must return the same hold, not create a second one that blocks the user's own seats.
Data model
shows(show_id PK, screen_id, movie_id, starts_at, layout_version, city_id). Indexed by(movie_id, city_id, starts_at)for the browse query.show_seats(show_id, seat_id, state, hold_id, hold_expires_at, booking_id, PRIMARY KEY (show_id, seat_id)). Partition key:show_id. Every seat of a show lives in one shard and in a handful of adjacent pages, so multi-seat holds are single-shard and the seat map is one range scan.stateisAVAILABLE,HELDorSOLD.holds(hold_id PK, show_id, user_id, seat_ids, expires_at, status, idempotency_key UNIQUE). Lets us find what a hold covers and makes hold retries idempotent. Status isACTIVE,CONFIRMED,RELEASEDorEXPIRED.bookings(booking_id PK, hold_id UNIQUE, user_id, show_id, amount, payment_intent_id UNIQUE, status, created_at). Secondary index on(user_id, created_at)for "my bookings". The unique constraint onhold_idmeans a confirmed hold can only ever produce one booking, which makes confirm retries safe.booking_seats(show_id, seat_id, booking_id, UNIQUE (show_id, seat_id)). A belt-and-braces constraint: even a bug in the hold logic cannot insert two bookings for the same seat.
Postgres or MySQL fits because the critical path needs atomic multi-row conditional updates and unique constraints. Shard by city in practice: a city's shows fit one primary, and every show stays inside one shard.
High-level design
flowchart LR
C["Client app"] --> GW["API gateway"]
GW --> WR["Waiting room"]
WR --> BK["Booking service"]
GW --> CAT["Catalog service"]
CAT --> CC["Catalog cache"]
GW --> SM["Seat map service"]
SM --> SMC["Seat map cache"]
SMC -. miss .-> DB[("Inventory DB by show")]
BK --> DB
BK --> PAY["Payment provider"]
PAY -- webhook --> BK
BK --> Q["Outbox events"]
Q --> N["Notifications"]
Q --> SMC
Q --> TH["Theater POS sync"]Write path. The client picks seats and calls create-hold. If the show is flagged hot, the gateway routes the call through the waiting room, which admits users at a rate the database can absorb. The booking service runs one transaction on the show's shard: a conditional update that flips the requested seats from available (or expired-held) to held, checks that the updated row count equals the number of requested seats, and inserts the hold row. If the count is short, it rolls back and returns which seats were taken. On success the client creates a payment intent with the provider, using the hold ID as the provider's idempotency key so a retried request replays the stored result instead of charging twice (Stripe), and the user pays. Confirm runs a second transaction that checks the hold is still the current owner of every seat and unexpired, flips seats to sold, inserts the booking and its seats, and writes an outbox event in the same commit.
Read path. Browse queries are almost entirely served from cache, since showtimes change a few times a day. The seat map service serves GET /seats from a cache keyed by (show_id, version) with a TTL of one or two seconds. A miss reads the show's 200 rows in one range scan from a replica and treats rows with a past hold_expires_at as available. Clients poll a tiny version endpoint every few seconds, or receive a push over server-sent events, and fetch the full map only when the version changes. Server-sent events suit this well: on reconnect the browser sends a Last-Event-ID header, so the server can resume from the last version the client saw (WHATWG HTML spec).
Deep dives
Holding seats without double booking
Options considered. First, pessimistic locking: SELECT ... FOR UPDATE on the seats, check them in application code, then update. It is correct but holds locks across an app round trip. Second, a Redis lock per seat with SET key value NX PX 480000. It is fast, but Redis replication is asynchronous, so a failover can lose a lock and let a second user hold the same seat, a race the Redis docs themselves spell out (Redis docs); the database still has to arbitrate. Third, one conditional UPDATE that makes the check and the write a single statement.
Choice: the conditional update. The statement sets state='HELD', hold_id=$h, hold_expires_at=now()+interval '8 min' where show_id=$s AND seat_id = ANY($seats) AND (state='AVAILABLE' OR (state='HELD' AND hold_expires_at <= now())). If the returned row count is less than the number of seats requested, roll back. Because expiry is part of the predicate, an expired hold is reclaimable immediately; a sweeper exists only to tidy holds.status and emit cache-invalidation events. Redis can sit in front as an optional fast-fail filter during spikes, never as the authority.
The payment race
The nastiest bug in this system is a payment that succeeds after the hold expired and someone else took the seat. Three mitigations combine. When the client starts payment, the service moves the hold into a PAYMENT_PENDING sub-state and extends it once by a few minutes, so a slow 3-D Secure challenge does not lose the seat. Confirm checks ownership with hold_id=$h rather than with time alone: if another hold now owns any seat, confirm fails. And confirm failure after a successful charge triggers an automatic refund through the same idempotent payment intent, plus an apology notification. We prefer authorise-then-capture: authorise when the user pays, capture only after confirm commits. A failed confirm then voids the authorisation, which is cheaper and faster for the user than a refund. An online card authorisation usually stays valid for about 7 days (Stripe docs), so the capture never races the authorisation's own expiry; the only clock that matters is the hold's.
Payment webhooks and client confirm calls can both arrive. Both call the same confirm path, and the unique constraint on bookings.hold_id turns the second one into a no-op that returns the existing booking.
Opening night: one show, thousands of clicks
For the hottest shows, 5,000 hold attempts per second land on 200 rows. Conflicts serialise them, and most fail anyway because the seats are gone within a minute. Letting the database absorb it saturates connection pools for every show on the shard, and sharding further is impossible because the hot spot is one show.
Choice: a virtual waiting room keyed by show or by film release. Users get a signed, time-limited admission token in random or arrival order, and the gateway admits perhaps 100 users per second per hot show, matched to measured seat throughput. Production waiting rooms expose exactly these two knobs: Cloudflare's is configured with a cap on total active users and a rate of new users per minute (Cloudflare). Inside the booking service, requests for one show can also be serialised through a per-show in-memory queue on the shard owner, which turns lock contention into a cheap FIFO. Seat-map reads for hot shows come from the cache with a one-second TTL and request coalescing, so 30,000 viewers cause one database read per second.
Down the stack
Layer 1The contract
A successful hold grants the caller exclusive rights to those seats until expires_at as measured by the database clock. No other hold can include those seats before then. A hold is all or nothing: either every requested seat is held or none is. Confirm succeeds only if the hold still owns every seat and has not expired; it is idempotent per hold, so retries return the same booking. Seat-map reads may be up to about two seconds stale; holds and confirms are linearisable per show because they run on its primary.
Layer 2The mechanism
In Postgres, the hold UPDATE scans the primary key index for the requested (show_id, seat_id) pairs, takes a row-level lock on each tuple (recorded in the tuple's xmax field), and writes a new tuple version. If another transaction holds a lock on one of those rows, ours waits. Under Read Committed, when the blocker commits, Postgres re-evaluates our WHERE clause against the newly committed version (the "EvalPlanQual" recheck) (PostgreSQL docs). If the seat is now HELD and unexpired, the row no longer matches, is skipped, and our row count comes up short. That recheck is what makes the single-statement check-and-set safe without Serializable isolation.
Deadlocks are avoided by having every transaction lock rows in one consistent order (PostgreSQL docs). Note that an UPDATE locks rows in the order its plan visits them, not in the order of the seat_ids array, so sorting the array alone proves nothing. An index scan on the (show_id, seat_id) key happens to visit seats in key order; to make the order explicit whatever the planner picks, run SELECT ... WHERE ... ORDER BY seat_id FOR UPDATE first, which sorts before it locks (PostgreSQL docs), then the update. Because state and hold_expires_at are not indexed, Postgres can usually perform a HOT (heap-only tuple) update that writes no new index entries, but only if the old row's page has room for the new version; a fillfactor below 100 on show_seats reserves that room (PostgreSQL docs).
Layer 3The machine
A show's 200 seat rows at about 60 bytes each fit in two 8 KB heap pages. Reads are cheap (two buffer hits in RAM), but every hold on that show briefly takes an exclusive content lock on the same page. Commit cost is dominated by the WAL flush. On NVMe, fdatasync of a WAL page takes roughly 50 to 200 microseconds, and group commit batches concurrent transactions into one flush (Postgres's commit_delay lets a leader wait briefly so more commits share its sync (PostgreSQL docs)). With synchronous replication to a standby in another availability zone, add one network round trip, about 0.5 to 1 ms. A hold transaction therefore costs about 1 to 3 ms end to end on the database. Conflicting holds on the same rows serialise, so one show tops out somewhere in the hundreds to low thousands of holds per second, which is why admission control, not hardware, is the fix for opening night.
Why it's built this way
- Humans need minutes to choose and pay, and payment runs in another company's system, so we cannot hold a database transaction open; we model the reservation as data with an expiry.
- Users abandon holds and processes crash, so release cannot depend on anyone remembering to clean up; expiry is a predicate on time evaluated at every access.
- Correctness needs one arbiter with atomic conditional writes, so the database row is the truth and caches only accelerate reads.
- Contention is naturally scoped to a show, so partitioning by show keeps every critical transaction on one shard and lets the rest of the fleet scale out.
- A single show's rows cannot be split further, so the only lever at the hot spot is admitting fewer concurrent writers.
Failure modes
- 10x load across the board. Catalog and seat-map reads scale out via cache and replicas. The booking DB is the limit: watch connection-pool saturation and lock-wait time, and turn on the waiting room for any show whose hold rate crosses a threshold.
- Region outage. Inventory for a city lives in one home region with a synchronous standby in a second availability zone. Losing the region means failing over to an asynchronous cross-region replica that may lack the last second of writes. Mitigation: bookings are also durably recorded at the payment provider, so a reconciliation job replays provider-confirmed payments against the new primary and refunds any that now conflict. During failover, holds return 503 rather than risk split-brain.
- Hot key. One show, covered above. Also the catalog entry for a premiere film: serve it from CDN with a short TTL so the catalog service sees one request per edge per few seconds.
- Payment provider slow or down. Holds expire mid-payment. Extend
PAYMENT_PENDINGholds up to a cap, and pause new holds when the provider error rate crosses a threshold. - Clock skew. All expiry comparisons use the database's
now(), so there is one clock per show.
In the wild
- Ticketmaster, 2022: the Eras Tour presale. About 3.5 million fans pre-registered for Verified Fan and roughly 1.5 million got codes, but bots and fans without codes drove 3.5 billion total system requests, four times Ticketmaster's previous peak. Ticketmaster slowed or postponed some sales to stabilise, which lengthened queues, and estimated that about 15% of interactions had problems, including passcode errors that dropped tickets already in carts. More than 2 million tickets still sold that day, a single-day record. Admission control held the inventory system up; the cost moved into the queue (Ticketmaster, archived).
- Cloudflare, 2021: building a waiting room that agrees with itself. Cloudflare's Waiting Room, first used for COVID-19 vaccine booking sites, is configured as total active users plus new users per minute. Users are bucketed by arrival minute and the oldest buckets are admitted first. Each data centre's share of new slots is based on its traffic two minutes earlier, coordinated through per-data-centre Durable Objects reporting to one global object; Workers KV was ruled out because it is only eventually consistent. One county site absorbed surges of up to 23,000 users (Cloudflare).
- Stripe, 2019: when the failover is the outage. A database minor version deployed three months earlier carried a latent bug, so when a shard's primary failed the shard could not elect a new one and stopped taking writes; most API requests failed for about 27 minutes. The rollback then interacted with a new configuration setting and caused a second, longer outage. Stripe added monitoring for stalled replication and per-cluster circuit breakers. It is the reason this design returns 503 on holds during a failover instead of guessing (Stripe).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Conditional UPDATE in the DB | Single arbiter, atomic, simple to reason about | Hot rows serialise; DB is the bottleneck | Default for seat inventory |
| Redis SET NX holds | Very fast, cheap TTL handling | Async replication can lose locks; needs DB check anyway | Front-line filter for spikes only |
| SELECT FOR UPDATE then update | Familiar, allows complex checks | Longer lock hold time, more deadlock risk | Holds need logic SQL cannot express |
| Waiting room | Protects DB, fair ordering | Extra infra, users wait | Any show with demand far above seats |
| Push seat map (SSE) vs poll | Fresher maps, fewer full fetches | Connection state at scale | Hot shows; poll is fine otherwise |
Leadership lens
- Build first:
show_seatsplus hold, confirm and release with a property-based test that fires concurrent holds and asserts no seat is ever in two bookings. Everything else is plumbing around that core. - Rollout: launch city by city behind a flag, reconciling with the theater POS nightly. Run the waiting room in shadow mode first to calibrate admission rates.
- Ownership: an inventory team owns holds and confirm; a payments team owns provider integration, refunds and reconciliation; catalog and seat-map read paths belong to a discovery team. The contract between inventory and payments (hold ID as payment idempotency key) is the most important interface to write down.
- Cost drivers: the booking DB sized for opening-night peaks, payment fees per transaction, and CDN egress for seat maps. Prefer admission control to over-provisioning that DB.
Check yourself
Why does the hold use a single conditional
UPDATEwith a row-count check instead of a RedisSET NXlock per seat?Show answer
Redis replication is asynchronous, so a failover can lose a lock and let a second user hold the same seat, which means the database has to arbitrate anyway. The conditional update makes the check and the write one atomic statement at the single source of truth, and a short row count rolls the whole hold back.
Why does the design not need a sweeper job to release expired holds for correctness?
Show answer
Expiry is part of the write predicate (
state='HELD' AND hold_expires_at <= now()counts as available), and seat-map reads treat pasthold_expires_atas available. A hold is dead the instant its time passes, so the sweeper only tidiesholds.statusand emits cache-invalidation events.
A card payment succeeds 30 seconds after the hold expired and another user now owns the seat. What does the system do?
Show answer
Confirm checks ownership by
hold_id, sees another hold owns the seat and fails, returning 410. Because the design authorises then captures, the failed confirm voids the authorisation (or refunds through the same idempotent payment intent) and the user gets an apology notification.
Using the lesson's numbers, why is opening night a contention problem that more hardware or sharding cannot fix?
Show answer
The hottest shows see about 5,000 hold attempts per second against just 200 rows that fit in two 8 KB pages, and conflicting holds serialise, so one show tops out in the hundreds to low thousands of holds per second. A single show cannot be split further, so the only lever is a waiting room that admits about 100 users per second per hot show.
Sources and further study
Primary
- A Critique of ANSI SQL Isolation Levels (Berenson, Bernstein, Gray et al., SIGMOD 1995): defines the anomalies (lost update, write skew) that a conditional update on a seat row must rule out.
- Server-sent events (WHATWG HTML Living Standard): the reconnection and
Last-Event-IDrules behind pushed seat-map versions.
Systems docs
- Transaction isolation (PostgreSQL docs): exactly how Read Committed re-checks a
WHEREclause after waiting on a row lock. - Heap-only tuples (PostgreSQL docs): when an update skips index writes, and how
fillfactorhelps. - Distributed locks with Redis (Redis docs): the failover race that makes a Redis-only hold unsafe, stated by Redis itself.
- Place a hold on a payment method (Stripe docs): authorise-then-capture, authorisation windows by card network, and cancelling an authorisation.
Books
- Designing Data-Intensive Applications (Kleppmann, 1st ed., 2017), Ch 7 "Transactions": lost updates, write skew and the case for atomic conditional writes.
- System Design Interview, Volume 2 (Xu and Lam, 2022), Ch 7 "Hotel Reservation": the same inventory-hold problem with rooms instead of seats.
Other curricula
- Queue-Based Load Leveling (Azure Architecture Center): frames the waiting room as a buffer in front of a service, and warns it does not fit callers that need a synchronous answer.
- Throttling pattern (Azure Architecture Center): frames admission as a control loop and argues for rejecting early and cheaply, with 429 or 503 plus
Retry-After.
Watch
- System Design Interview: Design Ticketmaster w/ a Ex-Meta Staff Engineer (Hello Interview): a closely related interview walkthrough covering seat reservations and a waiting room for surges.
- "Transactions: myths, surprises and opportunities" by Martin Kleppmann (Strange Loop Conference): what isolation levels really guarantee, which is the foundation of the conditional update.
Free resources
- Explicit locking (PostgreSQL docs): row lock modes and how deadlocks are detected, relevant to multi-seat holds.
- Range types and exclusion constraints (PostgreSQL docs): a database-native way to forbid overlapping reservations when seats become time slots.
- How to do distributed locking (Martin Kleppmann): why a lease in Redis is not enough for correctness without fencing.
- Cloudflare Waiting Room (Cloudflare blog): how a production virtual queue admits users at a controlled rate.
- Making retries safe with idempotent APIs (AWS Builders' Library): the idempotency-token pattern behind safe hold and confirm retries.