Design Movie Ticket Booking
The core test is concurrency: two people must never buy the same seat.
- 5 minseat hold before it expires
- ~100K req/sseat-map reads without a CDN
- 200 per secondshoppers admitted per hot show
Show what to say
“Every seat is a state machine: available, held for five minutes, then booked. I hold all the requested seats with one conditional UPDATE, so Postgres row locks pick exactly one winner per seat and nobody double-books. Payment carries an idempotency key, and the verified webhook confirms only if the hold is still mine, otherwise it refunds. Launches get a waiting room.”
Trade-offs
- Conditional UPDATE vs SELECT FOR UPDATE: one round trip and short locks, but less flexible logic.
- DB holds vs Redis holds: one source of truth, at the cost of more database writes.
- Countdown vs silently extending holds: fair and predictable, but some sales are lost.
Bring it up before they ask
Double-booking is the one to raise: the hold is a single conditional UPDATE under row locks, and a payment landing after the hold expired books only if the hold is still mine, otherwise it refunds.
Seats go available → held → booked. One conditional UPDATE holds every seat or none, so row locks pick one winner. A late payment is refunded.
Functional
- Browse shows and seat maps
- Hold seats for 5 minutes, pay, confirm
- Release seats on expiry or payment failure
- Cancel a booking
Non-functional
- Never double-book, ever
- Seat maps fresh within about 1–2 s
- Survive 100× spikes for big releases
- Idempotent payments
Out of scope
- Recommendations
- Dynamic pricing
- Refund policy details
The real test: two people must never buy the same seat.
Scope, scale and API
Ask: how many seats per booking, and how long is a hold?
Read more · 200 words
Is payment confirmed at once, or by ? Waitlists? Assigned seats, or general admission?
The average is tiny. The spike is the problem.
| Quantity | Estimate |
|---|---|
| Bookings | 10M a month ≈ 4 per second |
| Launch spike | 500K people in minutes |
| Seat-map reads, no CDN | ≈ 100K requests/s |
| Seat-map reads, 1 s CDN cache | About 1 origin request per show per second |
| Admitted shoppers | 200 per second per hot show |
| Hold writes | A few hundred per second: easy |
Seat maps are mostly read, so them.
The API:
| Method | Path | Result |
|---|---|---|
| GET | /shows/:id/seats | Seat-map snapshot (CDN-cached for 1–2 s) |
| GET | /shows/:id/seats/stream | SSE stream of seat changes |
| POST | /shows/:id/holds | { seats } → 201 { holdId, expiresAt }, or 409 if taken |
| POST | /payments | { holdId } + Idempotency-Key → payment intent |
| POST | /webhooks/payments | Provider confirms → booking confirmed or refunded |
| DELETE | /holds/:id | Release early |
During a launch, holds also need a waiting-room token.
Data model
Each seat is a small state machine:
Read more · 34 words
available → held: setsheld_byandheld_until.held → booked: payment confirmed.held → available: expired, released, or payment failed.
The key (show_id, seat_no) is one row per seat: exactly what Postgres locks.
CREATE TABLE seats (
show_id bigint NOT NULL,
seat_no text NOT NULL,
status text NOT NULL DEFAULT 'available' CHECK (status IN ('available', 'held', 'booked')),
held_by bigint,
held_until timestamptz,
booking_id bigint,
PRIMARY KEY (show_id, seat_no)
);
CREATE INDEX seats_expiring ON seats (held_until) WHERE status = 'held'; -- keeps the sweeper cheap
CREATE TABLE bookings (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL,
show_id bigint NOT NULL,
seats text[] NOT NULL,
payment_id text NOT NULL UNIQUE, -- a retried webhook can't book twice
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);Architecture
Play Hold, pay, confirm, then Seat taken, Late payment refunded and Launch-day spike.
Read more · 88 words
Average load is about 4 bookings a second, so the UPDATE alone copes. Add the waiting room only for launches. The hold length is a sum too:
| Hold | What happens |
|---|---|
| 1 minute | Too short to type a card |
| 5 minutes | Enough to pay; abandoned seats return soon |
| 15 minutes | 150 shoppers holding 2 seats each can lock a 300-seat show |
Alice and Bob click A5 in the same millisecond. With a SELECT then an UPDATE, both see "available" and both pay. One conditional UPDATE lets exactly one win.
Tap any box to see what it is and what it talks to.
- Client
- CDN
- Queue
- Service
- Pub/Sub
- External API
- Database
Text description
- Alice opens show 42. GET /shows/42/seats goes to the CDN.
- The CDN serves a snapshot at most a second or two old. The database does no work for this read.
- She picks A5 and A6. POST /shows/42/holds isn't cacheable, so the CDN passes it through.
- The booking API receives the hold request.
- One conditional UPDATE marks both seats held for 5 minutes, but only rows that are available or whose hold has expired.
- rowCount is 2, matching the request: COMMIT. Both seats are Alice's until the hold expires.
- The API publishes 'A5, A6 held' on show:42:seats.
- Every API instance receives it once and pushes it over SSE to its viewers: their seat maps grey out A5 and A6 within a second.
- Alice clicks Pay. The API creates a payment intent with Idempotency-Key = holdId, so a double click can't charge her twice.
- The provider's signed webhook arrives: payment succeeded.
- confirmBooking inserts the booking (a retried webhook hits UNIQUE payment_id and does nothing), then flips the seats to booked WHERE they're still held by Alice.
- Both rows match: COMMIT. A5 and A6 are booked for good.
- Alice's page shows the booking confirmed.
Step-by-step answer
State the invariant aloud
"A seat is booked at most once." Say it before you draw.
Seat state machine
available → held → booked, or back toavailable.Hold atomically
One statement for all seats. If
rowCountfalls short, roll back.UPDATE seats SET status = 'held', held_by = $1, held_until = now() + interval '5 minutes' WHERE show_id = $2 AND seat_no = ANY($3) AND (status = 'available' OR (status = 'held' AND held_until < now()))Mind the brackets, or booked seats with an old
held_untilmatch too.Why it's safe
Bob's UPDATE waits on Alice's row lock, re-checks the WHERE clause and matches 0 rows.
Payment
Idempotency-Key = holdIdstops double charges. The confirms, not the browser.Confirm
Insert the booking once, then update seats only
WHERE held_by = me. Hold lost? Refund.Expiry
Expired holds already count as free. A sweeper tidies up every 30 s.
Live seat map
A snapshot plus changes pushed over (why SSE).
Launch spikes
- A waiting room admits N users a second. Holds without a token are rejected.
- Cached seat maps, rate limits, a CAPTCHA.
Redis holds?
Only as a front filter. The database stays the truth.
Implementation
Read more · 26 words
holdSeatsremoves duplicate seats and publishes only afterCOMMIT.confirmBookingchecksheld_by, notheld_until.joinQueueusesZADD NX, so a refresh keeps your place.
// Only an EXPIRED HOLD counts as free. `status = 'available' OR held_until < now()` would also match booked seats.
export async function holdSeats(pool, redis, { showId, seats, userId }) {
const wanted = [...new Set(seats)]; // dedupe so rowCount can match
if (wanted.length === 0 || wanted.length > 10) throw new Error('Pick 1–10 seats');
const c = await pool.connect();
try {
await c.query('BEGIN');
const { rowCount } = await c.query(
`UPDATE seats SET status = 'held', held_by = $1, held_until = now() + interval '5 minutes'
WHERE show_id = $2 AND seat_no = ANY($3)
AND (status = 'available' OR (status = 'held' AND held_until < now()))`,
[userId, showId, wanted]);
if (rowCount !== wanted.length) {
await c.query('ROLLBACK'); // all or nothing
return { ok: false, reason: 'Some of those seats were just taken' };
}
await c.query('COMMIT');
await redis.publish(`show:${showId}:seats`, JSON.stringify({ seats: wanted, status: 'held' }));
return { ok: true, seats: wanted, expiresInSec: 300 };
} catch (e) {
await c.query('ROLLBACK').catch(() => {});
throw e;
} finally {
c.release();
}
}Failure modes
Read more · 38 words
| What breaks | Effect | Mitigation |
|---|---|---|
| Late webhook | Seat may be taken | Book if still mine; else refund |
| Webhook retried | Duplicate booking | UNIQUE (payment_id) |
| Double-click on Pay | Two charges | Idempotency key |
| Launch-day herd | DB overwhelmed | Waiting room, cache, limits |
Trade-offs
Read more · 131 words
| Choice | Pros | Cons |
|---|---|---|
Conditional UPDATE vs SELECT … FOR UPDATE | One round trip, short locks | Less flexible |
| DB holds vs Redis holds | One source of truth | More DB writes |
| Countdown vs extending holds | Fair and predictable | Some lost sales |
| SSE vs polling | Fresh within a second | A connection per viewer |
Follow-up questions
What if payment succeeds after the hold expires?
Still yours? Book it. Taken? Refund automatically. A countdown keeps it rare.
Seat-level or show-level locking?
Seat-level. A show-level lock would make every booking wait in line.
How do you survive a launch-day thundering herd?
A waiting room, seat maps, per-account limits and pre-scaling.
How do you stop bots from grabbing all the seats?
A CAPTCHA at the queue, per-account limits, and checks for suspiciously fast buyers.
How would you suggest the best available seats?
Score seats by distance from the centre, find N free in a row, and hold them with the same UPDATE.
Check yourself
Question 1 of 3Score 2 out of 3 or better to mark this page complete.