Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

System Design (High-Level Design)  Module 26 – Case studies: booking, commerce and contention

Design movie seat booking like BookMyShow

Design movie seat booking like BookMyShow: seat holds that expire while people pay, no seat sold twice, payment callbacks and a waiting room.

  • Intermediate
  • 40 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free: 40 minutes

What you will learn

  • Estimate seat-map reads, holds, storage and bandwidth for a national booking platform
  • Design seat holds that take a whole group or nothing and expire while the buyer pays
  • Prevent double sales with a unique constraint and conditional updates
  • Handle payment callbacks that repeat, arrive out of order or arrive after a hold has expired
  • Size a waiting room and a seat-map cache for a blockbuster's opening day
  • Explain how bookings stay in step with the cinemas' own ticketing systems

Before you start

What you’ll learn · Before you start

What you will learn

  • Estimate seat-map reads, holds, storage and bandwidth for a national booking platform
  • Design seat holds that take a whole group or nothing and expire while the buyer pays
  • Prevent double sales with a unique constraint and conditional updates
  • Handle payment callbacks that repeat, arrive out of order or arrive after a hold has expired
  • Size a waiting room and a seat-map cache for a blockbuster's opening day
  • Explain how bookings stay in step with the cinemas' own ticketing systems

Before you start

On this page

A movie booking system has one job that must never go wrong: each seat of each show is sold once, to one buyer, while thousands of people look at the same seat map. Four moves do it. The seats a buyer picks are held for a few minutes by one conditional update that takes the whole group or nothing. The payment runs while the hold lasts, and its result arrives as callbacks that can repeat or come late, so the code that handles them is idempotent. A row per sold seat, under a unique constraint on show and seat, is the final guarantee that no seat goes to two people, whatever a race or a bug does. And for a blockbuster’s opening day, a waiting room lets buyers in at the rate the booking system can take, while seat maps come from caches that are a second or two old. This case study goes from requirements and estimates to the API, the data model and the design, then answers every deep dive with a model or a program that you can run.

Not affiliated

Based on public information about BookMyShow. MySmartCoPilot is not affiliated with BookMyShow, and BookMyShow’s real architecture may differ from what this lesson describes.

The problem and its scope

Ask a few questions before drawing anything, because each answer changes the design:

  • What is sold? Seats in cinemas, each with a row and a number. Events with general admission, such as a standing concert, sell a count instead, which is a simpler problem.
  • How big? A national platform: assume 12,000 screens, 4 or 5 shows a screen each day, and about a million bookings a day, with Friday evenings and holidays well above the average.
  • How many seats per booking? Up to 10, chosen on a seat map, usually 2 to 4 seats side by side.
  • Who else sells the same seats? The cinema’s own box office and other platforms. Either the platform gets a block of seats per show, or it books through the cinema’s ticketing system, live.
  • How do people pay? Cards, bank transfers and payment apps through a payment gateway; some payments take seconds and a few take minutes.

That gives the requirements. Buyers find shows by city, film, cinema and time; they see a show’s seat map with prices and current availability; they pick seats and hold them while they pay; a successful payment confirms the booking and issues a ticket, and a failed or abandoned one releases the seats; bookings can be cancelled under the cinema’s rules. Food and drink, reviews, recommendations and dynamic pricing are left out.

The qualities matter more than the features. No seat is ever sold twice, and a buyer who paid always ends up with a ticket or a refund. A hold should be answered quickly, say within 300 ms for 99 % of requests, because the buyer is watching a spinner. Seat maps may be a second or two old, but no more. Browsing should be available 99.95 % of the time; booking, when the database for a show cannot be reached, refuses rather than risks a double sale. And the system must survive opening days that bring many times the usual traffic within minutes.

Estimates

The assumptions, so that anyone can disagree with them: 1,000,000 bookings a day with 2.5 seats each; buyers open 15 seat maps for every booking they make; the busiest hour carries 10 % of a day’s traffic; each of 12,000 screens runs 4.5 shows a day, of 250 seats each.

Quantity Working Result
Seat-map reads, average 1,000,000 × 15 ÷ 86,400 s about 174 a second
Seat-map reads, busiest hour 10 % of 15 million ÷ 3,600 s about 417 a second
Holds, busiest hour 100,000 bookings × 1.3 tries ÷ 3,600 s about 36 a second
Bookings stored 1 KB each × 1,000,000 × 365 × 3 copies about 1.1 TB a year
Seat rows 12,000 × 4.5 × 250 = 13.5 million a day, about 60 bytes each about 0.8 GB a day
Availability sent 417 reads × 1 KB × 8 bits about 3.3 Mbit/s

These numbers are small. A single well-run database could take 36 holds a second, and 1.1 TB a year is modest. What makes seat booking hard is contention: on an opening day, a few hundred shows of one film draw hundreds of thousands of people in the first minutes, and they all want the same seats. Assume 400,000 people in the first 10 minutes for 300 shows of 250 seats: 75,000 seats, about 30,000 bookings, and more than ten people for every booking that can succeed. That rush, not the daily average, shapes the design.

The API

Eight calls cover a booking. The read calls are cacheable; the write calls carry an idempotency key, so a request that a phone sends again after a timeout never creates a second hold or a second payment.

  • GET /v1/shows?city=…&film=…&date=… lists shows; it is the same for everyone, so a CDN and caches serve it.
  • GET /v1/layouts/{layoutId}/{version} returns a screen’s rows, seat numbers and price categories. A layout changes rarely, and a change gets a new version and so a new address, which lets it be cached for a year.
  • GET /v1/shows/{showId}/availability returns the layout version, an availability version and one bit per seat.
  • POST /v1/shows/{showId}/holds with the seats and an Idempotency-Key header answers 201 with a hold id, the amount and the expiry time, or 409 with the seats that were taken and a fresh availability.
  • POST /v1/holds/{holdId}/payment creates a payment order with the gateway, using the hold id as its idempotency key, and returns what the app needs to show the payment page.
  • POST /v1/payments/events receives the gateway’s signed callbacks; GET /v1/bookings/{bookingId} returns the booking and its ticket; DELETE /v1/holds/{holdId} releases a hold early.

Stripe’s API reference describes the behaviour that idempotency keys give: the server saves the status and body of the first request made with a key and returns them to every retry, compares the parameters of a retry with the original, accepts keys of up to 255 characters, and may remove a key once it is at least 24 hours old (Stripe). Holds need the same rule for a shorter time: a retried hold request returns the hold that the first request made.

UUID Generator Make random version 4 UUIDs, a good shape for idempotency keys.

The data model

The inventory database is partitioned by show, so everything about one show’s seats lives in one partition and every hold is a transaction on a single partition. Shows are independent of each other, which is what makes the partitioning safe: no booking ever spans two shows.

Table Key What it holds
shows show_id Screen, film, start time, layout version, on sale or closed
show_seats (show_id, seat_id) State (available, held, booked), the hold id and the time the hold ends
holds hold_id; (user_id, idempotency_key) unique Seats, amount, expiry, state
booked_seats (show_id, seat_id) unique The booking that owns the seat: the last line of defence
bookings booking_id; hold_id unique Buyer, show, amount, payment, ticket
payment_events event_id Every gateway event already handled
outbox id Events to publish, written in the same transaction as the change

A unique constraint ensures that the values of a column, or a group of columns, are unique among all the rows of the table, and PostgreSQL builds a unique index for it (PostgreSQL). On booked_seats (show_id, seat_id) it turns “no seat sold twice” from a promise of the code into a rule of the database. The outbox table follows the transactional outbox pattern: a change and the event that announces it are saved in one transaction, and a separate worker publishes the event, so the two can never disagree (Microsoft).

The high-level design

Seat booking: app, CDN, waiting room, API gateway, cached reads, booking and payment services, inventory database and an event queue.App or websiteCDNposters, show lists,seat layoutsWaiting roomhot shows onlyAPI gatewayauth, rate limitsShow times andseat mapscached readsBooking serviceholds, confirmationsInventory databasepartitioned by showPayment servicePayment gatewayEvent queuebooked, released, expiredTickets, cinema sync,reportsadmittedrefreshevery 1-2 sconditionalupdatesordercallbacksoutbox rows,same transaction

The first complete design of seat booking

Text description of the diagram

The diagram shows the parts of the design from top to bottom.

  1. The app or website reads posters, show lists and seat layouts from a CDN.
  2. For a hot show, the app passes a waiting room first; admitted buyers, and every buyer of an ordinary show, call the API gateway, which checks who they are and applies rate limits.
  3. Behind the gateway, a read service answers show times and seat maps from a cache, refreshed from the inventory database every one or two seconds.
  4. The booking service takes holds and confirms bookings with conditional updates in the inventory database, which is partitioned by show.
  5. The booking service asks the payment service to create a payment order with the payment gateway, which reports the result back with callbacks; a dashed arrow shows them.
  6. Every change to a booking also writes an outbox row in the same database transaction; those rows are published to an event queue (booked, released, expired), which feeds tickets and messages, the sync with cinema systems and reports.

Follow one buyer through it. Browsing never touches the inventory database: show lists, posters and layouts come from the CDN, and availability comes from the seat-map service, which reads each show’s seats from the database at most once a second and serves every viewer from that copy. Holding is one call to the booking service, which runs one conditional update in the show’s partition. Paying goes from the booking service to the payment service and the gateway; the result comes back as callbacks. Confirming writes the booking, its seats and an outbox row in one transaction. After the booking, the outbox worker publishes events to a queue that feeds tickets and messages, the cinema’s ticketing system and reports. The ticket itself is the booking id and seats with a signature, shown as a QR code, so a scanner at the door can check it even when the cinema’s connection is down. A waiting room sits in front only when a show is expected to be hot.

Every seat moves through three states, and each arrow is a single, checkable change:

A seat goes from available to held by a conditional update, to booked when payment is confirmed, and back to available when a hold ends.AvailableHeld by one holduntil a timeBookedone row in booked_seats,unique per show and seathold: a conditional updatefor the whole grouppayment confirmedwhile the hold is liveexpired, cancelledor payment given upcancelled under thecinema's rules, refunded

The life of one seat of one show

Text description of the diagram

The diagram shows the three states of one seat of one show and the arrows between them.

  1. An available seat becomes held when a hold takes it, with one conditional update for the whole group of seats the buyer chose.
  2. A held seat becomes booked when the payment is confirmed while the hold is still live. A booked seat is one row in the booked_seats table, which is unique per show and seat.
  3. A held seat becomes available again when its hold expires, when the buyer cancels or when the payment is given up.
  4. A dashed arrow leads from booked back to available: a booking cancelled under the cinema's rules, with a refund, puts the seat back on sale.

Five deep dives follow, the ones a design round keeps coming back to: how a group of seats is held without double sales, how long a hold should last, how payment callbacks are handled, how the opening-day rush is absorbed, and how the platform stays in step with the cinemas.

Deep dive: holding a group of seats, all or nothing

Two buyers who see seat F-8 available at the same moment both try to hold it. If the code reads the seat, finds it available and then writes the hold as two separate steps, both reads can happen before either write, and both buyers get the seat. The fix is to check and write in one statement whose condition the database evaluates while it writes: UPDATE show_seats SET state = 'held', hold_id = ?, held_until = ? WHERE show_id = ? AND seat_id IN (…) AND (state = 'available' OR (state = 'held' AND held_until <= ?)). Only one of two such updates can change the row.

Groups add a second problem. A family of four wants F-7 to F-10 together or not at all. Taking the seats one at a time, each with its own conditional update, is safe against double sales, but between two of those updates another buyer can take the next seat, and then the first buyer holds two seats nobody wants and must give them back. The run below gives the same 260 buyers, in groups of 1 to 6, the same 120-seat screen twice: once taking seats one at a time, and once with one update for the whole group inside one transaction, which is rolled back if the update changed fewer rows than the group has seats.

Group holds: one seat at a time, or all or nothing Python · group_holds.py
# Group holds on one show: 260 buyers want 1 to 6 adjacent seats of a 120-seat screen within a few seconds.
# Two ways to take a group's seats run on the same buyers, arriving in the same order:
#   one seat at a time   a conditional update per seat; on a clash, give back what was taken and pick again
#   all or nothing       one conditional update for the whole group, inside one transaction
# A seeded scheduler decides whose step runs next, the way a busy server interleaves requests, so every run
# prints the same. Whatever the hold logic does, booked_seats has UNIQUE (show_id, seat_id): a seat is sold once.
import random
import sqlite3

ROWS, PER_ROW = "ABCDEFGHIJ", 12
SEATS = [f"{r}-{n}" for r in ROWS for n in range(1, PER_ROW + 1)]
GROUP_SIZES = [1, 2, 2, 2, 3, 4, 4, 5, 6]
BUYERS, SHOW, TRIES = 260, 7, 3


def open_db():
    db = sqlite3.connect(":memory:", autocommit=True)  # BEGIN, COMMIT and ROLLBACK are written out below
    db.executescript("""
        CREATE TABLE show_seats (show_id INTEGER, seat_id TEXT, state TEXT NOT NULL DEFAULT 'available',
                                 hold_id INTEGER, PRIMARY KEY (show_id, seat_id));
        CREATE TABLE booked_seats (show_id INTEGER, seat_id TEXT, booking_id INTEGER, UNIQUE (show_id, seat_id));
    """)
    db.executemany("INSERT INTO show_seats (show_id, seat_id) VALUES (?, ?)", [(SHOW, s) for s in SEATS])
    return db


def pick(db, size, rng):
    """The first block of `size` adjacent available seats in a randomly ordered list of rows, or None."""
    open_seats = {s for (s,) in db.execute("SELECT seat_id FROM show_seats WHERE show_id = ? AND state = 'available'", (SHOW,))}
    for row in rng.sample(ROWS, len(ROWS)):
        for start in range(1, PER_ROW - size + 2):
            block = [f"{row}-{n}" for n in range(start, start + size)]
            if all(s in open_seats for s in block):
                return block
    return None


def give_back(db, buyer):
    db.execute("UPDATE show_seats SET state = 'available', hold_id = NULL WHERE hold_id = ? AND state = 'held'", (buyer,))


def pay_then_confirm(db, buyer, size, rng, stats):
    for _ in range(rng.randint(3, 8)):          # the buyer is paying; other requests run meanwhile
        yield
    if rng.random() < 0.15:                     # gave up at the payment page
        give_back(db, buyer)
        stats["abandoned"] += 1
        return
    db.execute("BEGIN IMMEDIATE")
    held = [s for (s,) in db.execute("SELECT seat_id FROM show_seats WHERE hold_id = ? AND state = 'held'", (buyer,))]
    db.executemany("INSERT INTO booked_seats VALUES (?, ?, ?)", [(SHOW, s, buyer) for s in held])
    db.execute("UPDATE show_seats SET state = 'booked' WHERE hold_id = ? AND state = 'held'", (buyer,))
    db.execute("COMMIT")
    stats["seated"] += 1
    stats["groups of 4 to 6 seated"] += size >= 4


def partial_holder(db, seat, partial):
    """Was this seat refused because a buyer held it as part of a group they had not finished taking?"""
    row = db.execute("SELECT hold_id FROM show_seats WHERE show_id = ? AND seat_id = ?", (SHOW, seat)).fetchone()
    return row is not None and row[0] in partial


def one_seat_at_a_time(db, buyer, size, rng, stats, partial):
    for _ in range(TRIES):
        seats = pick(db, size, rng)
        if seats is None:
            stats["no block left"] += 1
            return
        yield                                   # time passes between reading the map and writing
        taken = 0
        partial.add(buyer)
        for seat in seats:
            changed = db.execute("UPDATE show_seats SET state = 'held', hold_id = ? "
                                 "WHERE show_id = ? AND seat_id = ? AND state = 'available'", (buyer, SHOW, seat)).rowcount
            if changed == 0:
                stats["clashes"] += 1
                stats["clashes with a half-taken group"] += partial_holder(db, seat, partial)
                break
            taken += 1
            yield                               # another request can run between two seats
        partial.discard(buyer)
        if taken == size:
            yield from pay_then_confirm(db, buyer, size, rng, stats)
            return
        if taken:
            stats["half-taken groups given back"] += 1
            stats["seat-holds wasted"] += taken
        give_back(db, buyer)
        yield
    stats["gave up after 3 tries"] += 1


def all_or_nothing(db, buyer, size, rng, stats, partial):
    for _ in range(TRIES):
        seats = pick(db, size, rng)
        if seats is None:
            stats["no block left"] += 1
            return
        yield                                   # time passes between reading the map and writing
        marks = ", ".join("?" * size)
        db.execute("BEGIN IMMEDIATE")
        changed = db.execute(f"UPDATE show_seats SET state = 'held', hold_id = ? WHERE show_id = ? "
                             f"AND seat_id IN ({marks}) AND state = 'available'", (buyer, SHOW, *seats)).rowcount
        if changed == size:
            db.execute("COMMIT")
            yield from pay_then_confirm(db, buyer, size, rng, stats)
            return
        db.execute("ROLLBACK")                  # nothing of the group stays held
        stats["clashes"] += 1
        yield
    stats["gave up after 3 tries"] += 1


def run(strategy):
    db, rng = open_db(), random.Random(2026)
    sizes = [rng.choice(GROUP_SIZES) for _ in range(BUYERS)]
    stats = {k: 0 for k in ("seated", "groups of 4 to 6 seated", "abandoned", "clashes", "clashes with a half-taken group",
                            "half-taken groups given back", "seat-holds wasted", "gave up after 3 tries", "no block left")}
    partial, active, arrived = set(), [], 0
    while arrived < BUYERS or active:
        if arrived < BUYERS and rng.random() < 0.6:   # a rush: a new buyer arrives at most steps
            active.append(strategy(db, arrived, sizes[arrived], rng, stats, partial))
            arrived += 1
        if active:
            i = rng.randrange(len(active))
            try:
                next(active[i])
            except StopIteration:
                active.pop(i)
    sold = db.execute("SELECT COUNT(*), COUNT(DISTINCT seat_id) FROM booked_seats").fetchone()
    stats["seats sold"], stats["distinct seats sold"] = sold
    stats["seats unsold"] = db.execute("SELECT COUNT(*) FROM show_seats WHERE state = 'available'").fetchone()[0]
    return db, stats


results = {}
for name, strategy in (("one seat at a time", one_seat_at_a_time), ("all or nothing", all_or_nothing)):
    db, results[name] = run(strategy)
print(f"{BUYERS} buyers, groups of 1 to 6, a {len(SEATS)}-seat screen\n")
print(f"{'':34}{'one seat at a time':>20}{'all or nothing':>16}")
for key in results["all or nothing"]:
    print(f"{key:34}{results['one seat at a time'][key]:>20}{results['all or nothing'][key]:>16}")

# The last line of defence: a confirm path with a bug that skips the hold check still cannot sell a seat twice.
seat, owner = db.execute("SELECT seat_id, booking_id FROM booked_seats ORDER BY rowid LIMIT 1").fetchone()
try:
    db.execute("INSERT INTO booked_seats VALUES (?, ?, ?)", (SHOW, seat, 9999))
except sqlite3.IntegrityError as e:
    print(f"\nA buggy confirm tried to sell {seat} again: IntegrityError: {e}")

Output

260 buyers, groups of 1 to 6, a 120-seat screen

                                    one seat at a time  all or nothing
seated                                              55              35
groups of 4 to 6 seated                              3              18
abandoned                                           10               5
clashes                                            383              69
clashes with a half-taken group                    341               0
half-taken groups given back                        98               0
seat-holds wasted                                  129               0
gave up after 3 tries                               56               3
no block left                                      139             217
seats sold                                          99             120
distinct seats sold                                 99             120
seats unsold                                        21               0

A buggy confirm tried to sell I-1 again: IntegrityError: UNIQUE constraint failed: booked_seats.show_id, booked_seats.seat_id

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 group_holds.py

Taking seats one at a time sold 99 seats and left 21 unsold, although 56 buyers gave up after three tries. Most of its 383 clashes, 341 of them, were with a group that was only half taken and was about to be given back, so buyers were refused seats that nobody would end up buying. Large groups suffered most: 3 groups of 4 to 6 got seats, against 18 when each group was taken all or nothing. The all-or-nothing version sold all 120 seats, with 69 clashes and no seat ever held by a group that did not get the rest of its seats. It seated fewer buyers, 35 against 55, because it seated the big groups that the other version kept failing.

The last line shows the unique constraint at work. A confirmation path with a bug that skips the hold check tried to sell a booked seat again, and the database refused it. The constraint does not make the hold logic unnecessary, because a buyer who has already paid must not be refused at the last step, but it makes sure that a bug turns into an error and a refund, never into two people with tickets for the same seat.

Three details complete the hold. In PostgreSQL, two updates that lock overlapping seats in different orders can deadlock; PostgreSQL detects the deadlock and aborts one of the transactions, and its documentation advises taking locks on several objects in a consistent order (PostgreSQL), so sort the seat ids and retry a transaction aborted by a deadlock. When the buyer asks for “the best two seats” instead of choosing, SELECT … FOR UPDATE SKIP LOCKED lets each request lock different available seats without queueing behind the others; PostgreSQL warns that skipping locked rows gives an inconsistent view, suitable for queue-like work rather than general queries (PostgreSQL), which describes picking any available seat from a block. And a fast cache can screen holds before the database, for example SET seat-key hold-id NX PX 300000 in Redis, which sets a key only if it does not exist and gives it an expiry in milliseconds (Redis); the conditional update in the database still decides, because a cache can lose keys when it fails over.

Deep dive: how long a hold should last

A hold is a promise to one buyer and a refusal to everyone else, so its length is a trade. Too short, and people who are still paying lose their seats: their payment arrives late and needs a refund or a new seat check. Too long, and seats sit held for people who closed the app, while other buyers are told the show is full. The model below takes 20,000 checkouts with assumed payment times (most pay within two or three minutes, a few need much longer because of a slow bank page or a second payment method) and counts both costs for several hold lengths.

Late payments against seats held for nobody Python · hold_ttl.py
# How long should a seat hold last? 20,000 checkouts on a busy evening. Buyers spend a little while on the
# summary page, start paying, and most payments finish within two or three minutes; a few need much longer (a
# slow bank page, a second payment method). A short hold turns slow payers into late payments, which need a
# refund or a new seat; a long hold keeps seats away from other buyers while people who walked away still hold
# them. All times are assumptions of this model, not measurements of any platform. Seeded: every run prints
# the same.
import math
import random

CHECKOUTS = 20_000
rng = random.Random(42)


def lognormal(median_s, sigma):
    return median_s * math.exp(rng.gauss(0, sigma))


# (share, median seconds, spread) of the time from starting a payment to the gateway's confirmation.
METHODS = [(0.45, 40, 0.5), (0.35, 80, 0.45), (0.12, 140, 0.5), (0.08, 380, 0.4)]


def payment_time():
    x, acc = rng.random(), 0.0
    for share, median, sigma in METHODS:
        acc += share
        if x < acc:
            return lognormal(median, sigma)
    return lognormal(METHODS[-1][1], METHODS[-1][2])


def checkout():
    """(kind, started, finished): 'paid'; 'left' before paying (finished is None); 'stalled', a payment that was
    started and never finished."""
    r, started = rng.random(), lognormal(45, 0.6)
    if r < 0.72:
        return "paid", started, started + payment_time()
    if r < 0.92:
        return "left", started, None
    return "stalled", started, None


people = [checkout() for _ in range(CHECKOUTS)]


def policy(ttl_s, cap_s=None):
    """Late payments, and hold-minutes spent on buyers who never paid. With cap_s, a payment started while the
    hold is live extends it once, to cap_s from the start of the hold."""
    late = wasted = 0.0
    for kind, started, finished in people:
        ends = ttl_s
        if cap_s and kind != "left" and started < ttl_s:
            ends = cap_s
        if kind == "paid":
            late += finished > ends
        else:
            wasted += ends / 60          # closing the app tells the server nothing: the hold runs out
    return late, wasted


paid = sum(k == "paid" for k, _, _ in people)
print(f"{CHECKOUTS:,} checkouts: {paid:,} paid, {CHECKOUTS - paid:,} never paid\n")
print(f"{'hold':>19}{'late payments':>15}{'per 1,000':>11}{'hold-minutes':>14}")
print(f"{'':>19}{'':>15}{'checkouts':>11}{'for nobody':>14}")
rows = [(f"{m} min", policy(m * 60)) for m in (3, 5, 7, 10, 15, 20)]
rows.append(("5 min, then to 10", policy(5 * 60, 10 * 60)))
for label, (late, wasted) in rows:
    print(f"{label:>19}{int(late):>15,}{late / CHECKOUTS * 1000:>11.1f}{wasted / CHECKOUTS * 1000:>14,.0f}")
print("\nhold-minutes for nobody: per 1,000 checkouts, minutes of holds that ended without a payment")

Output

20,000 checkouts: 14,408 paid, 5,592 never paid

               hold  late payments  per 1,000  hold-minutes
                                    checkouts    for nobody
              3 min          3,550      177.5           839
              5 min          1,369       68.5         1,398
              7 min            703       35.1         1,957
             10 min            224       11.2         2,796
             15 min             30        1.5         4,194
             20 min              4        0.2         5,592
  5 min, then to 10            234       11.7         1,796

hold-minutes for nobody: per 1,000 checkouts, minutes of holds that ended without a payment

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hold_ttl.py

A 3-minute hold makes 177.5 payments in every 1,000 checkouts late; a 20-minute hold makes almost none late, but keeps seats for 5,592 minutes per 1,000 checkouts for people who will never pay. The last row is the policy worth using: hold for 5 minutes, and extend the hold once, to 10 minutes from its start, when the buyer actually starts a payment. It has almost the same late payments as a flat 10-minute hold, 11.7 against 11.2 per 1,000, and wastes 36 % fewer hold-minutes, because people who walk away before paying never get the extension.

Three rules make holds work in practice. The expiry is checked inside the query that takes seats, using the database’s clock, so a seat is available again the moment its hold ends even if no background job has run; a sweeper still marks expired holds and publishes “released” events so that seat maps update. The app shows the buyer the time left, from the server’s expiry time, so nobody is surprised. And the payment order is created with an expiry no later than the hold’s last minute, so a payment cannot start after the seats are gone.

Holds are also a target. Holding seats repeatedly without paying, to make a show look sold out or to keep seats for resale, is listed by OWASP as an automated threat, denial of inventory (OWASP). Allow one live hold per account per show, at most 10 seats in it, and a few holds an hour per account and device; cards and accounts that hold often and never pay are rate limited more tightly.

Deep dive: payment callbacks that repeat, reorder or arrive late

The payment is a saga of three steps: the hold, which can be undone by releasing it; the payment, after which there is no going back; and the confirmation, which must then be retried until it succeeds. Microsoft’s saga pattern calls these compensable, pivot and retryable transactions, and notes that retryable ones must be idempotent (Microsoft).

The gateway reports the result with callbacks, and gateways are explicit that callbacks are imperfect. Razorpay delivers webhooks at least once, so the same event can arrive several times; it gives each event a unique x-razorpay-event-id, counts any response other than 2xx, or no response within 5 seconds, as a failure, retries with exponential backoff for 24 hours, and says events may arrive out of order (Razorpay). Stripe retries for up to three days in live mode, does not guarantee the order of events, and advises logging the ids of processed events and returning a 2xx before any slow work (Stripe). So the handler checks the signature, records the event id in the same transaction as the change it makes, applies only changes that move a booking forward, and does the slow work, such as issuing the ticket, from the outbox afterwards.

Duplicates, a failure, an old event and two late payments JavaScript · payment_callbacks.mjs
// Payment callbacks for seat holds. A payment gateway delivers each event at least once, not always in order,
// and sometimes after the hold it pays for has expired. This handler confirms each paid hold exactly once,
// ignores repeats, and turns a payment that arrives too late into a fresh seat check or a refund.
// Times are seconds after the holds were taken; a hold lasts 300 s, extended once to 600 s when a payment starts.
const seats = new Map(); // seat -> { state: 'available' | 'held' | 'booked', hold }
const holds = new Map(); // hold id -> { seats, expiresAt, state }
const bookings = new Map(); // hold id -> booking: one per hold, never two
const processed = new Set(); // event ids already handled
const refunds = [];

for (const s of ['F-7', 'F-8', 'F-9', 'F-10', 'F-11', 'G-4', 'G-5', 'G-6']) seats.set(s, { state: 'available', hold: null });

function hold(id, list, now) {
  if (!list.every((s) => seats.get(s).state === 'available')) return false;
  for (const s of list) seats.set(s, { state: 'held', hold: id });
  holds.set(id, { seats: list, expiresAt: now + 300, state: 'held' });
  return true;
}

function paymentStarted(id, now) {
  const h = holds.get(id);
  if (h.state === 'held' && now < h.expiresAt) h.expiresAt = Math.max(h.expiresAt, 600);
}

function expire(now) {
  for (const [id, h] of holds) {
    if (h.state !== 'held' || now < h.expiresAt) continue;
    h.state = 'expired';
    for (const s of h.seats) if (seats.get(s).hold === id) seats.set(s, { state: 'available', hold: null });
    console.log(`${String(now).padStart(4)} s  ${id} expired: ${h.seats.join(', ')} back on sale`);
  }
}

function confirm(id, h) {
  for (const s of h.seats) seats.set(s, { state: 'booked', hold: id });
  h.state = 'confirmed';
  bookings.set(id, { seats: h.seats });
}

// In a real handler the event id is recorded in the same database transaction as the change it causes.
function handle(ev, now) {
  const at = `${String(now).padStart(4)} s  ${ev.id} ${ev.type.padEnd(18)} ${ev.hold}`;
  if (processed.has(ev.id)) return console.log(`${at}: already handled, ignored`);
  processed.add(ev.id);
  const h = holds.get(ev.hold);
  if (ev.type === 'payment.failed') return console.log(`${at}: payment failed, the hold stays until it expires`);
  if (ev.type === 'payment.authorized') {
    if (h.state === 'confirmed') return console.log(`${at}: older than what we know (already confirmed), ignored`);
    return console.log(`${at}: authorised, waiting for capture`);
  }
  if (bookings.has(ev.hold)) return console.log(`${at}: booking exists, nothing to do`);
  if (h.state === 'held' && now < h.expiresAt) {
    confirm(ev.hold, h);
    return console.log(`${at}: confirmed ${h.seats.join(', ')}`);
  }
  // Too late: the hold expired. Take the seats again if nobody else has them; otherwise refund.
  if (h.seats.every((s) => seats.get(s).state === 'available')) {
    confirm(ev.hold, h);
    return console.log(`${at}: late, but the seats were still unsold: confirmed ${h.seats.join(', ')}`);
  }
  h.state = 'refund-due';
  refunds.push(ev.payment);
  const lost = h.seats.filter((s) => seats.get(s).state !== 'available' && seats.get(s).hold !== ev.hold);
  console.log(`${at}: late, and ${lost.join(', ')} now belongs to another buyer: refund ${ev.payment}`);
}

hold('H1', ['F-7', 'F-8'], 0);
hold('H2', ['F-9', 'F-10', 'F-11'], 0);
hold('H3', ['G-4'], 0);
hold('H4', ['G-5', 'G-6'], 0);
const timeline = [
  [20, () => paymentStarted('H1', 20)],
  [40, () => paymentStarted('H2', 40)],
  [70, () => handle({ id: 'ev-101', type: 'payment.captured', hold: 'H1', payment: 'pay-1' }, 70)],
  [75, () => handle({ id: 'ev-101', type: 'payment.captured', hold: 'H1', payment: 'pay-1' }, 75)],
  [80, () => handle({ id: 'ev-102', type: 'payment.failed', hold: 'H2', payment: 'pay-2a' }, 80)],
  [95, () => handle({ id: 'ev-103', type: 'payment.captured', hold: 'H2', payment: 'pay-2b' }, 95)],
  [110, () => handle({ id: 'ev-099', type: 'payment.authorized', hold: 'H1', payment: 'pay-1' }, 110)],
  [300, () => expire(300)],
  [320, () => console.log(` 320 s  another buyer holds G-6: ${hold('H5', ['G-6'], 320)}`)],
  [430, () => handle({ id: 'ev-104', type: 'payment.captured', hold: 'H3', payment: 'pay-3' }, 430)],
  [470, () => handle({ id: 'ev-105', type: 'payment.captured', hold: 'H4', payment: 'pay-4' }, 470)],
  [480, () => handle({ id: 'ev-105', type: 'payment.captured', hold: 'H4', payment: 'pay-4' }, 480)],
];
for (const [, step] of timeline) step();

const sold = [...seats.entries()].filter(([, v]) => v.state === 'booked').map(([s]) => s);
console.log(`\nbookings: ${[...bookings.keys()].join(', ')} (${bookings.size}, one per paid hold that kept its seats)`);
console.log(`refunds:  ${refunds.join(', ')}`);
console.log(`seats booked: ${sold.join(', ')}; every one by exactly one hold: ${sold.every((s) => bookings.has(seats.get(s).hold))}`);

Output

  70 s  ev-101 payment.captured   H1: confirmed F-7, F-8
  75 s  ev-101 payment.captured   H1: already handled, ignored
  80 s  ev-102 payment.failed     H2: payment failed, the hold stays until it expires
  95 s  ev-103 payment.captured   H2: confirmed F-9, F-10, F-11
 110 s  ev-099 payment.authorized H1: older than what we know (already confirmed), ignored
 300 s  H3 expired: G-4 back on sale
 300 s  H4 expired: G-5, G-6 back on sale
 320 s  another buyer holds G-6: true
 430 s  ev-104 payment.captured   H3: late, but the seats were still unsold: confirmed G-4
 470 s  ev-105 payment.captured   H4: late, and G-6 now belongs to another buyer: refund pay-4
 480 s  ev-105 payment.captured   H4: already handled, ignored

bookings: H1, H2, H3 (3, one per paid hold that kept its seats)
refunds:  pay-4
seats booked: F-7, F-8, F-9, F-10, F-11, G-4; every one by exactly one hold: true

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node payment_callbacks.mjs

Every case is in the run. The repeated ev-101 and ev-105 were ignored. A failed payment for H2 left its hold in place, so the buyer’s second attempt confirmed it. An authorized event that arrived after captured was older than what the system knew and changed nothing. Two payments came after their holds had expired: H3’s seat was still unsold, so it was taken again and confirmed, while one of H4’s seats now belonged to another buyer, so H4’s payment was marked for a refund. Three bookings, one refund, and every booked seat owned by exactly one hold.

HMAC Generator Compute an HMAC-SHA256 signature, the kind most gateways put on their callbacks, to check a signature by hand.

Callbacks are not the only path. A payment can be interrupted between the buyer, the bank and the gateway, and Razorpay describes such late authorisation, where money may or may not have left the buyer’s account and no result has come back yet (Razorpay). A reconciliation job therefore asks the gateway for the status of every payment still pending a few minutes after it started, and treats the answer like a callback. Where the gateway allows it, capture the payment only after the seats are confirmed: Razorpay lists the states created, authorized, captured, refunded and failed, and refunds an authorised payment that is not captured within 3 days (Razorpay), so a late payment that cannot be seated is simply never captured.

Deep dive: the opening-day rush

Go back to the rush: 400,000 people in 10 minutes for 75,000 seats. Without a waiting room, they all reach the booking system at once, most of them clash over the same good seats, timeouts lead to retries, and the retries add to the load just when it is highest. A waiting room keeps the booking system at a rate it can take. Every visitor gets a ticket number; a “now serving” cursor moves forward at the admission rate; a visitor whose number the cursor has passed gets a signed admission token, which the booking API checks with the waiting room’s key alone. Fastly’s waiting-room tutorial builds the same machine from two counters and a signed cookie (Fastly).

Tickets, a cursor and signed admission tokens Python · admission_tokens.py
# A waiting room for an opening-day rush: a ticket counter, a "now serving" cursor that moves at the rate the
# booking system can take, and signed tokens, so that the booking API can check an admission without asking
# the waiting room. Tokens are HMAC-SHA256 signatures over a small JSON payload; the secret below is a demo
# value (a real one lives in a secret store and rotates). Time is passed in, so every run prints the same.
import base64
import hashlib
import hmac
import json

SECRET = b"demo-secret-of-the-waiting-room"


def b64(data):
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def sign(payload):
    body = b64(json.dumps(payload, separators=(",", ":")).encode())
    return body + "." + b64(hmac.new(SECRET, body.encode(), hashlib.sha256).digest())


def read(token, kind, event, now):
    """(payload, "ok") if the token is genuine, of this kind, for this event and not expired; else (None, why)."""
    try:
        body, mac = token.split(".")
        if not hmac.compare_digest(mac, b64(hmac.new(SECRET, body.encode(), hashlib.sha256).digest())):
            return None, "bad signature"
        payload = json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)))
    except ValueError:
        return None, "not a token"
    if payload["kind"] != kind or payload["event"] != event:
        return None, f"it is a {payload['kind']} token"
    if now >= payload["exp"]:
        return None, "expired"
    return payload, "ok"


class WaitingRoom:
    def __init__(self, event, per_second):
        self.event, self.per_second = event, per_second
        self.next_ticket, self.admitted_up_to, self.since = 1, 0, 0.0
        self.sold_out = False

    def join(self, now):
        ticket, self.next_ticket = self.next_ticket, self.next_ticket + 1
        return sign({"kind": "queue", "event": self.event, "ticket": ticket, "exp": now + 3600})

    def advance(self, now):
        """Move the cursor at the admission rate; once the booking system says sold out, it never moves again."""
        if not self.sold_out:
            steps = int((now - self.since) * self.per_second)
            self.admitted_up_to += steps
            self.since += steps / self.per_second

    def check(self, queue_token, session, now):
        self.advance(now)
        payload, why = read(queue_token, "queue", self.event, now)
        if payload is None:
            return "refused", why
        position = payload["ticket"] - self.admitted_up_to
        if position <= 0:
            return "admitted", sign({"kind": "admission", "event": self.event, "session": session, "exp": now + 600})
        if self.sold_out:
            return "sold out", "nobody else will be let in"
        return "waiting", f"position {position}, about {position / self.per_second:.1f} s"


room = WaitingRoom("show-day-1", per_second=2)
queue = {f"u{i}": room.join(now=0) for i in range(1, 13)}    # 12 people join in the opening second

for now, user in [(0, "u1"), (0, "u12"), (2, "u3"), (2, "u12"), (4, "u8")]:
    state, detail = room.check(queue[user], session=user, now=now)
    print(f"t={now} s  {user}: {state}" + ("" if state == "admitted" else f" ({detail})"))

_, admit = room.check(queue["u3"], session="u3", now=5)
print("\nThe booking API checks an admission token with the secret alone, without a call to the waiting room:")
print(f"  u3's admission token at t=5 s:    {read(admit, 'admission', 'show-day-1', 5)[1]}")
print(f"  the same token at t=605 s:        {read(admit, 'admission', 'show-day-1', 605)[1]}")
print(f"  u2's queue token used to book:    {read(queue['u2'], 'admission', 'show-day-1', 5)[1]}")
body, mac = queue["u12"].split(".")
forged = json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)))
forged["ticket"] = 1                                          # u12 edits the ticket number in its token
edited = b64(json.dumps(forged, separators=(",", ":")).encode()) + "." + mac
print("  u12's edited queue token:         %s: %s" % room.check(edited, session="u12", now=5))
room.sold_out = True                                          # the booking system: every seat is taken or held
print("  u12 after the sell-out, t=6 s:    %s: %s" % room.check(queue["u12"], session="u12", now=6))

Output

t=0 s  u1: waiting (position 1, about 0.5 s)
t=0 s  u12: waiting (position 12, about 6.0 s)
t=2 s  u3: admitted
t=2 s  u12: waiting (position 8, about 4.0 s)
t=4 s  u8: admitted

The booking API checks an admission token with the secret alone, without a call to the waiting room:
  u3's admission token at t=5 s:    ok
  the same token at t=605 s:        expired
  u2's queue token used to book:    it is a queue token
  u12's edited queue token:         refused: bad signature
  u12 after the sell-out, t=6 s:    sold out: nobody else will be let in

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 admission_tokens.py

The cursor moved at 2 people a second, so u3 was let in at 2 seconds and u8 at 4, while u12 saw its position fall from 12 to 8. The booking API accepted u3’s admission token, refused it 600 seconds later when it expired, refused a queue token presented as an admission token, and refused u12’s token after u12 had edited its ticket number, because the signature no longer matched. Signatures are compared with hmac.compare_digest, which avoids the timing differences of an ordinary comparison (Python). Once the show sold out, the cursor stopped and u12 was told so at once, instead of waiting for a seat that would never come.

The admission rate comes from Little’s law. Suppose an admitted buyer spends 4 minutes, 240 seconds, choosing seats and paying, and makes about 8 requests to the booking system on the way, and suppose the booking system can take 1,500 such requests a second, of which you plan to use 70 %. Then 1,050 requests a second allow 1,050 × 240 ÷ 8 = 31,500 buyers inside at once, and Little’s law (people inside = arrival rate × time inside) gives an admission rate of 31,500 ÷ 240, about 131 people a second. If three in four admitted buyers book, 30,000 bookings need about 40,000 admissions, which take about 5 minutes. People far down the queue learn early that the show sold out, which is kinder than a long wait and saves their requests too.

Two more choices keep the rush fair and cheap. Visitors who join before sales open get a random place among themselves, so refreshing at the exact second gains nothing, while later arrivals queue in order. And seat maps during the rush are cheap by construction. The layout is immutable and versioned, so the CDN serves it with Cache-Control: immutable, which RFC 8246 defines as a promise that the resource will not change while it is fresh, so clients need not revalidate it (RFC 8246). Availability is computed once a second for each show and served from that copy, so 300 hot shows cost the database 300 reads a second however many people are watching, and a 409 carries fresh availability, so a buyer who lost a seat sees the current map at once. OWASP lists getting scarce goods by unfair automated means as scalping (OWASP), so the waiting room also issues one ticket per signed-in account and challenges suspicious traffic before it gets a number.

Deep dive: the cinema’s own systems

The platform rarely owns the seats outright. In this design the cinema also sells at its own box office, through its own ticketing system, and possibly through other platforms, so the integration takes one of two shapes.

  • An allocation. The cinema gives the platform a block of seats per show, and the platform sells it as described so far. Unsold seats are handed back at a cut-off before the show. It is simple and fast, but seats outside the block cannot be sold even when the block has sold out.
  • Live inventory. Every hold and confirmation is also made in the cinema’s system through its API, which stays the source of truth. A hold then exists twice, and the shorter of the two expiries is the real one. Calls to the cinema’s system can be slow or fail, so each carries an idempotency key, and a booking can wait in a “confirming” state while the call is retried.

Either way, a reconciliation job compares the platform’s bookings with the cinema’s records for every show, regularly and once more shortly before the show starts. A seat that both sides think they sold is a problem to solve before two people arrive at it: the newer booking is moved to an equivalent seat if one exists, or refunded with an apology. The events of the outbox make the cinema’s view current within seconds, and reconciliation catches what events miss.

What breaks at 10× and 100×

Load What gives way first The change
10× (10 million bookings a day) Seat-map reads and the availability cache More cache replicas, availability pushed to watching apps instead of polled
10× The payment gateway’s rate limits Several gateways, a payment queue with backpressure, holds extended while a payment waits
100× (a national release on one day) Writes to the hottest shows’ partitions One queue per hot show, so one worker decides its holds in order
100× Messages and tickets after a wave of bookings Outbox events processed by many consumers, tickets made lazily when first opened
Any A region failing Shows partitioned so that a region owns its cinemas; a failed region refuses bookings instead of overselling

Failure drills

Each line pairs a failure with what this design does about it.

  1. The inventory database’s primary fails during a confirmation. The transaction either committed or did not. The payment handler retries with the same event; if the booking exists, the retry finds it and does nothing.
  2. The webhook endpoint is down for 20 minutes. Gateways retry for hours, and the reconciliation job asks for the status of pending payments, so bookings confirm late but not twice; holds extended for started payments cover most of the gap.
  3. The sweeper stops. Holds still expire, because every hold query checks the expiry time. Seat maps show expired holds as taken until the sweeper resumes, so an alert on its lag matters.
  4. The seat-map cache serves a stale map. Buyers pick taken seats and get a 409 with fresh availability; nothing is sold twice, and the clash rate shows the problem.
  5. A deploy ships a confirmation path that skips the hold check. The unique constraint refuses the second sale, the error rate rises, and the deploy is rolled back with refunds instead of angry customers at the cinema.
  6. The gateway is slow, with payments taking 30 seconds. Holds of started payments are extended to their cap, the waiting room slows its admission, and new holds stop when the payment queue is too long.

Options considered and rejected

Option Why it is rejected
A distributed lock per show around every booking Serialises all buyers of a show and depends on lock timing; the database’s conditional update already decides each seat
Holds kept only in a cache A failover can lose holds and sell a held seat; a cache may screen holds, but the database decides
Pay first, choose seats afterwards Fine for general admission; with seat maps, buyers would pay without knowing their seats
Holds of 20 minutes Almost no late payments, but seats held for nobody grow four times compared with 5 minutes extended to 10
Checking availability only at payment Buyers pay for seats someone else got, and every clash becomes a refund

What each level is expected to cover

  • Fresher: the requirements, the API, a table of seats with states, and a unique constraint against double sales.
  • Mid-level (2 to 5 years): holds with expiry, the conditional update for a whole group, idempotency keys, and estimates that find the opening-day rush.
  • Senior (5 to 10 years): the payment saga with duplicate and late callbacks, the hold length chosen with numbers, a waiting room sized with Little’s law, and partitioning by show.
  • Staff (10 years and more): integration with cinema systems and reconciliation, the failure drills, abuse and fairness, and what changes at 10× and 100×.

A 45-minute self-mock

Set a timer and say your answer aloud, or write it. Spend 5 minutes on scope questions and requirements, 5 on the estimates, 10 on the API, the data model and the high-level design, 15 on two deep dives of your choice (groups of seats and late payments are the usual pair), 5 on the opening-day rush and 5 on failures and trade-offs. Afterwards, go through your answer next to this page, one section at a time, and against the levels listed above.

Exercise and self-check

Exercise · Medium · Python

Hold a group of seats all or nothing, then confirm it

Write two functions in holds.py for the seat table of a cinema booking system. Each show's seats are rows of show_seats (show_id, seat_id, state, hold_id, held_until), where state is 'available', 'held' or 'booked', and held_until is a time in milliseconds. Times are passed in as now_ms, so the tests decide what "now" is. The connection is opened with autocommit=True, so write BEGIN, COMMIT and ROLLBACK yourself.

  • hold_group(db, show_id, seats, hold_id, now_ms, ttl_ms) holds every seat in the list for hold_id until now_ms + ttl_ms, or none of them. A seat can be taken if it is available, or if it is held by a hold whose held_until is not later than now_ms (that hold has expired). A booked seat can never be taken. Return True if the group is held; otherwise return False and leave every row as it was. An empty list returns False.
  • confirm(db, show_id, hold_id, now_ms) books the seats of a hold that is still live (held_until later than now_ms): their state becomes 'booked', and one row per seat goes into booked_seats (show_id, seat_id, hold_id), which is unique per show and seat. Return the number of seats booked. A hold that has expired books nothing and returns 0: the caller refunds that payment or looks for new seats.

For example, with seats A-1 to A-4 available, hold_group(db, 1, ["A-1", "A-2"], 7, 0, 300000) returns True; at now_ms=1000, hold_group(db, 1, ["A-2", "A-3"], 8, 1000, 300000) returns False and A-3 stays available. The sample tests create the tables, import your functions and run in your browser.

Starter code · holds.py

def hold_group(db, show_id, seats, hold_id, now_ms, ttl_ms):
    """Hold every seat in `seats` for `hold_id` until now_ms + ttl_ms, or none of them; True if held."""
    # Replace this line with your code.
    return False


def confirm(db, show_id, hold_id, now_ms):
    """Book the seats of a live hold; return how many were booked (0 if the hold has expired)."""
    # Replace this line with your code.
    return 0
The sample tests · test_holds.py
import sqlite3

from holds import confirm, hold_group

TTL = 300_000


def make_db():
    db = sqlite3.connect(":memory:", autocommit=True)
    db.executescript("""
        CREATE TABLE show_seats (show_id INTEGER, seat_id TEXT, state TEXT NOT NULL DEFAULT 'available',
                                 hold_id INTEGER, held_until INTEGER, PRIMARY KEY (show_id, seat_id));
        CREATE TABLE booked_seats (show_id INTEGER, seat_id TEXT, hold_id INTEGER, UNIQUE (show_id, seat_id));
    """)
    for show in (1, 2):
        db.executemany("INSERT INTO show_seats (show_id, seat_id) VALUES (?, ?)", [(show, f"A-{n}") for n in range(1, 5)])
    return db


def states(db, show_id=1):
    return {s: (st, h) for s, st, h in db.execute("SELECT seat_id, state, hold_id FROM show_seats WHERE show_id = ?", (show_id,))}


def test_holds_available_seats():
    """a group of available seats is held for the hold id"""
    db = make_db()
    assert hold_group(db, 1, ["A-1", "A-2"], 7, 0, TTL) is True
    s = states(db)
    assert s["A-1"] == ("held", 7) and s["A-2"] == ("held", 7) and s["A-3"] == ("available", None)
    assert db.execute("SELECT held_until FROM show_seats WHERE seat_id = 'A-1' AND show_id = 1").fetchone()[0] == TTL


def test_all_or_nothing():
    """one seat held by a live hold refuses the whole group and changes nothing"""
    db = make_db()
    hold_group(db, 1, ["A-1", "A-2"], 7, 0, TTL)
    before = states(db)
    assert hold_group(db, 1, ["A-2", "A-3"], 8, 1000, TTL) is False
    assert states(db) == before


def test_other_show_untouched():
    """seats with the same names in another show are separate seats"""
    db = make_db()
    hold_group(db, 1, ["A-1"], 7, 0, TTL)
    assert hold_group(db, 2, ["A-1"], 8, 0, TTL) is True
    assert states(db, 2)["A-1"] == ("held", 8)


def test_expired_hold_can_be_taken():
    """a hold whose time has come no longer protects its seats"""
    db = make_db()
    hold_group(db, 1, ["A-1"], 7, 0, TTL)
    assert hold_group(db, 1, ["A-1", "A-2"], 8, TTL, TTL) is True
    assert states(db)["A-1"] == ("held", 8)


def test_booked_seat_is_never_taken():
    """a booked seat stays booked, whatever its old hold time says"""
    db = make_db()
    hold_group(db, 1, ["A-1"], 7, 0, TTL)
    assert confirm(db, 1, 7, 1000) == 1
    assert hold_group(db, 1, ["A-1"], 8, 10 * TTL, TTL) is False
    assert states(db)["A-1"] == ("booked", 7)


def test_confirm_live_hold():
    """confirming a live hold books every seat once"""
    db = make_db()
    hold_group(db, 1, ["A-3", "A-4"], 9, 0, TTL)
    assert confirm(db, 1, 9, TTL - 1) == 2
    rows = sorted(db.execute("SELECT seat_id, hold_id FROM booked_seats WHERE show_id = 1"))
    assert rows == [("A-3", 9), ("A-4", 9)]


def test_confirm_expired_hold():
    """an expired hold books nothing, even when nobody else has taken its seats"""
    db = make_db()
    hold_group(db, 1, ["A-1"], 7, 0, TTL)
    assert confirm(db, 1, 7, TTL) == 0
    assert db.execute("SELECT COUNT(*) FROM booked_seats").fetchone()[0] == 0


def test_empty_group():
    """an empty list holds nothing"""
    db = make_db()
    assert hold_group(db, 1, [], 7, 0, TTL) is False
A hint

Do the check and the write in one UPDATE with the condition in its WHERE clause: state = 'available' OR (state = 'held' AND held_until <= ?), for seat_id IN (…). Inside a transaction, the update's rowcount tells you how many seats it took; if that is fewer than the seats asked for, ROLLBACK undoes the ones it did take.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 A platform makes 1,000,000 bookings a day, and buyers open 15 seat maps for every booking they make. How many seat-map reads a second is that on average?

    Type a number.

    Show the answer to question 1

    Answer: 174 reads per second (anything from 157 to 191 counts)

    1,000,000 × 15 = 15 million seat-map reads a day, and a day has 86,400 seconds: 15,000,000 ÷ 86,400 ≈ 174 reads a second. The average only sets the scale; the busiest hour decides the size of the read path.

  2. Question 2 of 6 The busiest hour of the day carries 10 % of the day's 15 million seat-map reads. How many reads a second is that?

    Type a number.

    Show the answer to question 2

    Answer: 417 reads per second (anything from 375 to 459 counts)

    10 % of 15 million is 1.5 million reads in 3,600 seconds: about 417 a second, 2.4 times the average. A cache that refreshes each show's availability once a second serves almost all of them.

  3. Question 3 of 6 Each booking, with its seats and payment references, takes about 1 KB, and the database keeps 3 copies. How many gigabytes do 1,000,000 bookings a day need in a year?

    Type a number.

    Show the answer to question 3

    Answer: 1095 GB (anything from 985 to 1205 counts)

    1,000,000 × 1 KB = 1 GB a day, 365 GB a year, and 3 copies make 1,095 GB, about 1.1 TB. That is small for one database; partitioning by show is for the write contention of hot shows, not for size.

  4. Question 4 of 6 A show's availability is sent as about 1 KB (a version and one bit per seat), while the seat layout comes from a CDN. At 417 reads a second, how many megabits a second leave the origin?

    Type a number.

    Show the answer to question 4

    Answer: 3.3 Mbit/s (anything from 2.97 to 3.63 counts)

    417 × 1 KB ≈ 417 KB a second, and 417 KB × 8 = about 3.3 Mbit/s. Keeping the large layout, with its versioned URL, on the CDN keeps the origin's share this small.

  5. Question 5 of 6 Which of these must answer before the buyer is told "your seats are held"?

    Choose every answer that is right.

    Show the answer to question 5

    Answer:

    • The inventory database that holds the show's seats
    • The booking service

    A hold is one conditional update in the show's partition of the inventory database, made by the booking service. Payment comes after the hold, the CDN serves only static files, and the event queue is fed after the change commits, so none of them may slow down or block a hold.

  6. Question 6 of 6 A payment succeeds two minutes after its hold expired, and nobody has taken those seats since. What does the design of this lesson do?

    Choose one answer.

    Show the answer to question 6

    Answer: It takes the seats again with a conditional update and confirms the booking

    A late payment is checked like a new hold: if every seat is still available, one conditional update takes them and the booking is confirmed. Only when another buyer holds or owns a seat is the payment refunded. Confirming without the check could sell a seat twice, and refusing every late payment refunds buyers for no reason.

Interview questions

Why does a booking system hold seats with an expiry instead of locking them until the payment finishes? A database lock held for the minutes a payment takes would keep a transaction open while a person types, block every other buyer who touches those rows, and depend on the connection staying alive. A hold is ordinary data instead: a state and an end time on each seat, written by a short transaction. Other buyers see the seat as taken and are refused at once instead of waiting. If the buyer leaves, nothing needs to be cleaned up for the seat to come back, because every query that takes seats treats a hold whose time has passed as no hold at all. A payment that finishes after the hold ended is checked again: the seats are taken again if they are still available, or the payment is refunded.

What does a unique constraint on show and seat guarantee that checks in the application cannot? It guarantees that the database will never store two sales of the same seat, whatever order concurrent requests run in and whatever bug a later change brings. A check in the application reads first and writes later, so two requests can both pass the check before either writes. The database enforces the constraint while it writes, inside the transaction, and refuses the second row with an error, which the application turns into a refund or a new seat choice. It is the last line of defence, not the first: holds keep the second buyer from ever reaching it in normal operation.

Key takeaways

  • Hold a group of seats with one conditional update inside one transaction, all or nothing; seat by seat, groups fail and seats go unsold.
  • Put a unique constraint on show and seat in the table of sold seats, so a race or a bug becomes an error, never a double sale.
  • Check expiry inside the query that takes seats; hold for about 5 minutes and extend once when a payment starts.
  • Treat payment callbacks as at least once and in any order: record each event id with its change, move bookings only forward, and turn late payments into a new seat check or a refund.
  • Size the waiting room’s admission rate from the booking system’s capacity with Little’s law, and stop admitting when the show sells out.
  • Partition by show, serve layouts from a CDN by version and availability from a copy refreshed every second, and reconcile with the cinema’s records before every show.

That was a complete case study.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.