Engineering Atlas › System Design Interviews

Design a cinema ticket booking service

19 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

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.

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

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

  1. 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.
  2. 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.
  3. Correctness needs one arbiter with atomic conditional writes, so the database row is the truth and caches only accelerate reads.
  4. 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.
  5. A single show's rows cannot be split further, so the only lever at the hot spot is admitting fewer concurrent writers.

Failure modes

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Conditional UPDATE in the DBSingle arbiter, atomic, simple to reason aboutHot rows serialise; DB is the bottleneckDefault for seat inventory
Redis SET NX holdsVery fast, cheap TTL handlingAsync replication can lose locks; needs DB check anywayFront-line filter for spikes only
SELECT FOR UPDATE then updateFamiliar, allows complex checksLonger lock hold time, more deadlock riskHolds need logic SQL cannot express
Waiting roomProtects DB, fair orderingExtra infra, users waitAny show with demand far above seats
Push seat map (SSE) vs pollFresher maps, fewer full fetchesConnection state at scaleHot shows; poll is fine otherwise

Leadership lens

Check yourself

  1. Why does the hold use a single conditional UPDATE with a row-count check instead of a Redis SET NX lock 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.

  1. 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 past hold_expires_at as available. A hold is dead the instant its time passes, so the sweeper only tidies holds.status and emits cache-invalidation events.

  1. 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.

  1. 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

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →