Notes
Listen
Clarify the flow. Users browse movies, cinemas and showtimes in their city, open a show's seat map, select seats, and the system holds those seats for them for a few minutes while they pay. Payment produces a ticket with a QR code. Non-functional: a seat is never sold twice, a hold is honoured for its full window, and the system survives release-day spikes. Food ordering, reviews and dynamic pricing are out of scope.
- Functional
- browse movies, cinemas, times
- see the live seat map
- hold chosen seats while paying
- pay and get a ticket
- cancel within policy
- Non-functional
- never sell a seat twice
- hold is fair: 10 min to pay
- survive release-day spikes
Ask scale
The averages are small: a quarter of a million shows a day, a million bookings, about twelve a second, and a thousand browsing requests a second. Storage is a few hundred gigabytes a year. The real number is the spike. On a big release, five million people arrive in ten minutes, around eight thousand seat-map requests a second, and a single popular show might have a hundred thousand people competing for two hundred seats. Design for that hot spot.
10,000 cinemas x 5 screens x 5 shows = 250,000 shows/day x 200 seats = 50M seats/day on sale 1M bookings/day = about 12/s avg browse: 10M DAU x 10 = 1,000 rps release day: 5M users in 10 minutes = 8,000 rps of seat-map views one show: 100,000 users, 200 seats storage: 1M x 1 KB x 365 = 365 GB/yr
Define API
Make the hold an explicit resource. Search shows by city, movie and date. Get a show's seat map with each seat's status and price. Create a hold for a list of seats: success returns a hold id and an expiry time, and conflict returns 409 with the seats someone else already took. Then create a booking from the hold with an idempotency key, so a retry after a flaky connection never books twice.
GET /v1/shows?city=pune&movie=12
&date=2026-10-24
GET /v1/shows/{id}/seats
-> [{ seat: "F7", status, price }]
POST /v1/shows/{id}/holds
{ seats: ["F7","F8"] }
-> 201 { hold_id, expires_at }
-> 409 { taken: ["F8"] }
POST /v1/bookings Idempotency-Key
{ hold_id, payment_method } Data model
This is relational data that needs transactions, so use SQL. The key table has one row per seat per show, with a status of available, held or booked, the hold id and its expiry, the booking id, and a version number. Bookings record the user, show, seats, amount and status. Shard everything by show id. Then all seats of one show live on one shard, and holding four seats together is a single local transaction.
shows(show_id, screen_id, movie_id, starts_at, price_tiers) show_seats(show_id, seat_id, status, hold_id, hold_expires_at, booking_id, version) PRIMARY KEY (show_id, seat_id) bookings(booking_id, user_id, show_id, seats[], amount, status, idem_key) shard by show_id: one show = one shard = local transactions only
High-level design
Here is the basic design. Browsing goes to a catalog service backed by a cache keyed by city and date, because showtimes change rarely. The seat map and holds go to the booking service, which talks to the seat database. Holding seats is one atomic update on that show's shard. So the expensive, contended part is small and isolated from the cheap, cacheable part.
Then the money. The user submits a booking for their hold. The booking service creates a pending booking and asks the payment service to charge. When payment succeeds, it flips the held seats to booked in one transaction and publishes an event, and a separate worker sends the ticket and QR code. Tickets are asynchronous; the seat confirmation is not.
Deep dive: seat holds
The heart of the design is the hold. In one transaction, update the requested seats to held, but only where they are available, or held with an expiry already in the past. If the number of rows updated equals the number of seats asked for, commit. Otherwise roll back and return 409, so the user never gets half a pair of seats. Notice expiry is lazy: we do not need a timer to free a seat, the next hold simply treats an expired hold as available.
Interviewers often ask pessimistic or optimistic. Pessimistic locking reads the seats with select for update, checks them, then writes, holding row locks the whole time; on a hot show, requests queue up behind each other. Optimistic means a single conditional update or a version check: no waiting, the loser simply sees zero rows and gets a 409. For short seat holds, optimistic wins. Concepts episode 31 covers locks, and the Dealing with contention pattern goes further.
A popular alternative keeps holds in Redis. Each held seat is a key set only if absent, with a ten minute expiry, and a small Lua script holds several seats all or nothing. Expiry is automatic. The SQL database then stores only confirmed bookings, with a unique constraint on show and seat as the final guard against double booking. It offloads hot shows, at the cost of two stores that must agree, so the unique constraint is not optional.
UPDATE show_seats
SET status = HELD, hold_id = :h,
hold_expires_at = now() + 10 min
WHERE show_id = :s
AND seat_id IN (F7, F8)
AND (status = AVAILABLE
OR (status = HELD
AND hold_expires_at < now()));
-- rows updated = 2 ? COMMIT
-- : ROLLBACK, return 409 - pessimistic: SELECT ... FOR
- UPDATE, then write
- + simple reasoning
- - holds row locks longer,
- queues pile up on hot shows
- optimistic: conditional update
- or version check, no lock wait
- + short, scales on hot rows
- - loser retries or gets 409
per seat: SET seat:{show}:F7 h91
NX PX 600000
several seats: Lua script, all or none
expiry is automatic (TTL)
booking: SQL insert with UNIQUE
(show_id, seat_id) as the final guard
+ takes load off SQL on hot shows
- two stores to keep in agreement Deep dive: pay and hold
Payment races the hold's clock. When the user enters the payment step, extend the hold once so a slow bank page does not cost them their seats. When payment succeeds, flip the seats to booked only where the hold id matches and it has not expired. If that updates every seat, confirm. If it updates none, the hold lapsed and someone else got the seats, so refund automatically and say so. Use the idempotency key on both the charge and the booking.
entering payment: extend hold once
to expires_at = now + 10 min
payment ok ->
UPDATE show_seats SET status = BOOKED
WHERE hold_id = :h
AND hold_expires_at > now()
rows = n -> CONFIRMED
rows = 0 -> seats lost: auto refund
idempotency key on charge + booking Deep dive: cancel
Cancellation is the booking flow in reverse. Within the policy window, one transaction marks the booking cancelled, but only if it is still confirmed, frees its seats, and writes a refund request to an outbox table so the refund is never lost. The seats publish a change event and reappear on everyone's map. The status guard means two cancel taps cannot refund twice.
allowed until 2 h before start BEGIN; UPDATE bookings SET status = CANCELLED WHERE id = :b AND status = CONFIRMED; UPDATE show_seats SET status = AVAILABLE WHERE booking_id = :b; INSERT INTO outbox (RefundRequested); COMMIT; seat map event: F7, F8 free again
Deep dive: hot releases
For a blockbuster, put a virtual waiting room in front. Everyone who arrives gets a signed token with a position in line. The room admits a fixed number of users per minute, sized to what the booking service can comfortably handle, and the gateway rejects booking calls without an admitted token. Users see their position and an estimated wait, which feels far fairer than error pages. Add a per-user seat limit to blunt resellers and bots.
- open bookings at a set time
- arrivals get a signed token
- with a random or FIFO position
- admit N users per minute,
- N = what booking can handle
- gateway rejects calls without
- an admitted token
- show position and estimated wait
- per-user limit: 10 seats
Deep dive: live seat map
A seat map that is a minute old leads to endless 409s and frustrated users. Publish an event for every hold, booking and expiry on a channel per show, and push those changes to everyone viewing that show with server-sent events, since updates only flow one way. For a hot show, serve the full map from a cache refreshed every second or two and push only the deltas. The map can be slightly stale; correctness still comes from the hold itself.
- every hold, booking, expiry
- -> event on channel show:881
- clients on that show subscribe
- via SSE (one-way is enough)
- hot show: cache full map for
- 1-2 s, push only deltas
- map may be a second stale;
- the hold returns 409 anyway
Deep dive: hot shard
Check that one shard can carry a hot show. The waiting room admits two thousand users a minute; each tries about three holds, so roughly a hundred conditional updates a second. A single relational primary handles thousands of short updates a second, so one show is fine. The real risk is fifty hot shows landing on the same shard. Place shows by expected demand, and move blockbuster shows onto their own shard before bookings open.
waiting room admits 2,000 users/min per hot show each user: ~3 hold attempts = 100 conditional updates/s one SQL primary: thousands/s -> one show is fine risk: 50 hot shows on one shard -> place shows by expected demand, move hot shows to their own shard
Trade-offs
Several dials are worth discussing. Hold length: longer holds let people who abandon lock seats, shorter ones make real buyers lose seats mid-payment, and ten minutes with one extension is a sensible start. Seat limits per user and per card. Whether the waiting room is first come or randomized at opening time. And the consistency map: seats are strongly consistent, listings and seat maps can be eventually consistent.
Failure modes. Booking servers are stateless and holds live in the database, so a crash loses nothing. A late payment webhook leaves the booking pending until reconciliation resolves it. A shard outage pauses only the shows on that shard. Monitor double bookings, which must be zero, the share of holds that become bookings, the 409 rate, and refunds caused by expired holds. At ten times the load, add shards and make the waiting room admit more slowly.
- hold length: 10 min
- longer: abandoners lock seats
- shorter: real payers lose out
- seat limit per user and card
- queue: random vs first come
- SQL holds vs Redis holds
- strong consistency on seats;
- eventual on listings, maps
- one shard per hot show: limit
- booking node dies: holds are
- in the DB, nothing is lost
- payment webhook late: booking
- stays pending, reconcile
- shard down: that show pauses,
- others keep selling
- watch: double bookings (0),
- hold to booking conversion,
- 409 rate, refund-after-expiry
- 10x: more shards, bigger room
Interview summary
In the interview, say this. The average load is small, the hot show is the problem. One row per seat per show in SQL, sharded by show id. A hold is a single conditional update that takes all seats or returns 409, with lazy expiry. Prefer optimistic updates to row locks. Confirm only where the hold id matches and has not expired, otherwise refund. A waiting room for big releases, and a pushed seat map.
- spike: 100k users, 200 seats
- SQL, one row per seat per show
- shard by show_id
- hold = one conditional update,
- all seats or 409, lazy expiry
- optimistic over row locks
- confirm where hold_id matches
- and not expired, else refund
- waiting room for releases
- SSE seat map, deltas only