BackendMedium40 min

Design Movie Ticket Booking

The core test is concurrency: two people must never buy the same seat.

Say it in 30 seconds
  • 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.

0 of 0 sections ticked
30-second answer

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.

QuantityEstimate
Bookings10M a month ≈ 4 per second
Launch spike500K people in minutes
Seat-map reads, no CDN≈ 100K requests/s
Seat-map reads, 1 s CDN cacheAbout 1 origin request per show per second
Admitted shoppers200 per second per hot show
Hold writesA few hundred per second: easy

Seat maps are mostly read, so them.

The API:

MethodPathResult
GET/shows/:id/seatsSeat-map snapshot (CDN-cached for 1–2 s)
GET/shows/:id/seats/streamSSE 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/paymentsProvider confirms → booking confirmed or refunded
DELETE/holds/:idRelease 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: sets held_by and held_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.

schema.sql
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:

HoldWhat happens
1 minuteToo short to type a card
5 minutesEnough to pay; abandoned seats return soon
15 minutes150 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.

A queue is a line at a ticket window: people wait in order and are served one at a time.

Press Play to watch it run · 13 steps

Seat-map reads come from the CDN and an SSE stream; every hold and booking is one conditional UPDATE on the Postgres primary. Redis carries seat changes between API instances and runs the launch-day waiting room.

Movie ticket booking architectureSeat-map reads come from the CDN and an SSE stream; every hold and booking is one conditional UPDATE on the Postgres primary. Redis carries seat changes between API instances and runs the launch-day waiting room.HTTPS + SSEwrites, missesadmitted?conditional UPDATEpublishintent / webhookBrowser / appCaches GET /shows/:id/seats for 1–2 s; passes writes and the SSE stream through.CDNseat map ~1 sFIFO queue plus admission keys, only switched on for hot shows.Waiting roomRedis ZSETBooking API×NRedis pub/subshow:*:seatsPayment providerintent + webhookThe source of truth. Row locks decide every seat.Postgres primaryseats, bookingsRead replicasnapshots

Tap any box to see what it is and what it talks to.

  • Client
  • CDN
  • Queue
  • Service
  • Pub/Sub
  • External API
  • Database
Open in playground
Text description
  1. Alice opens show 42. GET /shows/42/seats goes to the CDN.
  2. The CDN serves a snapshot at most a second or two old. The database does no work for this read.
  3. She picks A5 and A6. POST /shows/42/holds isn't cacheable, so the CDN passes it through.
  4. The booking API receives the hold request.
  5. One conditional UPDATE marks both seats held for 5 minutes, but only rows that are available or whose hold has expired.
  6. rowCount is 2, matching the request: COMMIT. Both seats are Alice's until the hold expires.
  7. The API publishes 'A5, A6 held' on show:42:seats.
  8. 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.
  9. Alice clicks Pay. The API creates a payment intent with Idempotency-Key = holdId, so a double click can't charge her twice.
  10. The provider's signed webhook arrives: payment succeeded.
  11. 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.
  12. Both rows match: COMMIT. A5 and A6 are booked for good.
  13. Alice's page shows the booking confirmed.
Interactive

Seat race

Both click A5 at once. Which query lets only one of them win?

Booking query
Alice · 40 msidleBob · 60 msidleDatabaseA1✕✕45678B1234✕✕78C✕23✕567✕seat A5: availableone statement at a time per row

Press the button: Alice (40 ms away) and Bob (60 ms away) both try to book A5 at the same instant.

Network delays
SQL log

No statements yet.

    Step-by-step answer

    1. State the invariant aloud

      "A seat is booked at most once." Say it before you draw.

    2. Seat state machine

      available → held → booked, or back to available.

    3. Hold atomically

      One statement for all seats. If rowCount falls 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_until match too.

    4. Why it's safe

      Bob's UPDATE waits on Alice's row lock, re-checks the WHERE clause and matches 0 rows.

    5. Payment

      Idempotency-Key = holdId stops double charges. The confirms, not the browser.

    6. Confirm

      Insert the booking once, then update seats only WHERE held_by = me. Hold lost? Refund.

    7. Expiry

      Expired holds already count as free. A sweeper tidies up every 30 s.

    8. Live seat map

      A snapshot plus changes pushed over (why SSE).

    9. Launch spikes

      • A waiting room admits N users a second. Holds without a token are rejected.
      • Cached seat maps, rate limits, a CAPTCHA.
    10. Redis holds?

      Only as a front filter. The database stays the truth.

    Idempotency is a PAID stamp on a receipt: stamping it twice still leaves it paid once.

    Implementation

    Read more · 26 words
    • holdSeats removes duplicate seats and publishes only after COMMIT.
    • confirmBooking checks held_by, not held_until.
    • joinQueue uses ZADD 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 breaksEffectMitigation
    Late webhookSeat may be takenBook if still mine; else refund
    Webhook retriedDuplicate bookingUNIQUE (payment_id)
    Double-click on PayTwo chargesIdempotency key
    Launch-day herdDB overwhelmedWaiting room, cache, limits

    Trade-offs

    Read more · 131 words
    ChoiceProsCons
    Conditional UPDATE vs SELECT … FOR UPDATEOne round trip, short locksLess flexible
    DB holds vs Redis holdsOne source of truthMore DB writes
    Countdown vs extending holdsFair and predictableSome lost sales
    SSE vs pollingFresh within a secondA 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 3

    Score 2 out of 3 or better to mark this page complete.

    1. Alice and Bob run the hold UPDATE for seat A5 at the same instant. Why can't both succeed?