Design a Ticketing Platform (Ticketmaster)

Design a platform that sells reserved-seat tickets for concerts and sports. Most of the time it is quiet: people browse events and buy a few tickets. A few times a year a superstar tour goes on sale and two million fans arrive within minutes for 50,000 seats per show.

A fan picks seats on a map, those seats are set aside while they pay, and the purchase either completes or the seats go back on sale. The platform must never sell the same seat twice, must stay up during the rush, and should feel fair: refreshing faster or running a bot should not beat a fan who arrived on time.

In 45 minutes, cover requirements and sizing, the browsing and purchase flows, and go deep on seat holds, the virtual waiting room and how you protect the inventory database from the spike.

Sketch on the HLD board, write your answer in the Design doc, and size it with the Estimator. Start the mock interview from the timer for a 45-minute run.

Design Path · Location & Marketplaces · problem 27 of 70

Ask before you design. Try answering each yourself, then compare.

  1. Reserved seats, general admission, or both?
    Typical answer

    Both. Reserved seats are the hard case; general admission is a counter per section.

  2. How big is the biggest on-sale?
    Typical answer

    Around 2 million fans in the first 10 minutes for a stadium show of ~50,000 seats, with several shows on sale at once.

  3. How long can a fan hold seats while paying?
    Typical answer

    About 5–8 minutes; then the seats return to the pool automatically.

  4. Is there a per-fan ticket limit?
    Typical answer

    Yes, typically 4–6 tickets per account per event, enforced at purchase.

  5. Must the seat map be exactly current?
    Typical answer

    No. A few seconds stale is fine, as long as the hold step itself is authoritative.

  6. Do we fight bots?
    Typical answer

    Yes, at the edge (verified accounts, rate limits, challenges), but deep bot detection is out of scope.

  7. Who processes payments?
    Typical answer

    An external payment provider; we authorise during checkout and capture on purchase.

Compare 2 million arriving fans with the rate at which checkouts can actually complete. What must sit between them?
Holding a seat and selling a seat are different states. How do expired holds come back without a person cleaning up?
Two fans click the same seat at the same millisecond. What single operation decides the winner?
Most traffic is people looking at the seat map. Does that need to read the source of truth?
If everyone who arrives early gets in first-come-first-served, what does that reward?

Requirements

Functional

  • Browse and search events; view a venue seat map with live-ish availability and prices.
  • Join a virtual waiting room for high-demand on-sales and be admitted in a fair order.
  • Select seats and hold them for a limited time; pay to convert the hold to tickets.
  • Release holds automatically on expiry or abandonment.
  • Enforce per-account ticket limits and issue tickets after payment.

Non-functional

  • No seat sold twice, ever; holds and sales are strongly consistent.
  • Absorb ~200,000 requests per second at the edge during an on-sale without the core going down.
  • Hold request p99 under 500 ms once a fan is admitted.
  • Seat map availability may lag up to ~5 s.
  • Core purchase path 99.99% available during on-sales.

Out of scope

  • Resale marketplace and ticket transfer.
  • Dynamic pricing algorithms.
  • Venue entry scanning and anti-fraud models.

Back-of-the-envelope estimates

  • Normal day: ~5 M visitors, ~100,000 tickets sold ≈ 1–2 purchases per second, ~200 page reads per purchase. Easy.
  • On-sale arrival: 2 M fans in 10 minutes ≈ 3,300 joins per second; each waiting browser polls its position every ~10 s, so ~200,000 lightweight requests per second, which must be served at the edge.
  • Checkout capacity: a stadium of 50,000 seats at ~2.5 tickets per order is ~20,000 orders. If checkout takes ~3 minutes, admitting ~2,000 fans per minute sells out in roughly 10–15 minutes while keeping the hold tier at a few hundred holds per second.
  • Seat-map reads: 20,000 admitted fans refreshing a map every few seconds ≈ 10,000 reads per second for one event; serve from a cached availability bitmap (50,000 seats ≈ 6 KB per section set).
  • Storage: seats and tickets are small: 100,000 tickets/day × 1 KB × 5 years ≈ 180 GB; the hard part is concurrency, not volume.

The design is driven by the ratio of arrivals to checkout throughput (~1,000:1): the waiting room converts a spike into a steady, chosen admission rate.

API

POST /v1/events/{id}/queue                 -> {queueTicket, position, etaSeconds}
GET  /v1/events/{id}/queue/{queueTicket}   -> {position} | {admitToken (signed, 15 min)}
GET  /v1/events/{id}/seatmap               -> {sections:[{id, availableBitmap, prices}]}   (cached)
POST /v1/events/{id}/holds   Authorization: admitToken   Idempotency-Key
     {seatIds:[...]}                         -> 201 {holdId, expiresAt} | 409 {unavailable:[...]}
POST /v1/holds/{holdId}/purchase  {paymentMethodId}  Idempotency-Key
                                             -> {orderId, tickets[]}

The admit token is a short-lived signed token that the gateway checks without a database call, so only admitted fans can reach the hold endpoint and the core never sees the crowd.

Data model

Inventory DB (relational, sharded by (event_id, section_id)): seat(event_id, section_id, seat_id, status, hold_id, hold_expires_at, order_id, version). Sharding by section spreads a single hot event over many shards while keeping a typical 2–4 seat request, which is almost always within one section, on one shard.

Holds and orders: hold(hold_id, fan_id, event_id, seat_ids, expires_at, status), order(order_id, fan_id, hold_id, amount, payment_ref, status, idempotency_key UNIQUE), ticket(ticket_id, order_id, seat_id, barcode). A per-event fan_quota(event_id, fan_id, count) enforces limits atomically.

General admission: a counter row per section updated with remaining = remaining - n WHERE remaining >= n.

Waiting room state (Redis): a sorted set per event of queue tickets by assigned position, plus a counter of how far admission has progressed. It is rebuildable and holds no money.

Seat-map cache: per-section availability bitmaps in Redis, refreshed from inventory change events.

High-level design

Arriving. When an on-sale opens, fans load the event page from the CDN and call the waiting room, which runs close to the edge. Fans who arrived before opening time get a random position; later fans join the back. The browser polls its position from cached, cheap endpoints. An admitter releases fans at a rate set by operators to match checkout capacity, issuing each a signed admit token that expires in 15 minutes.

Choosing seats. With the token, the fan reaches the API gateway, which verifies the signature locally and rate-limits per account. The event service returns the seat map from cached section bitmaps. When the fan picks seats, the inventory service runs one conditional update on the section's shard that moves those seats from free (or expired hold) to held, all or none. Losing a race returns 409 with the seats that went, and the map updates.

Paying. The order service checks the hold is still valid and the fan is under the ticket limit, authorises the card with the payment provider using the idempotency key, then in one transaction marks the seats sold and creates the order and tickets. Committed changes flow onto a seat-change stream that refreshes the seat-map cache. A hold expiry job returns lapsed holds to free, so abandoned checkouts reappear on the map within seconds.

Open the HLD board and press Reference to see the architecture diagram.

Deep dives

Holding seats without overselling

The only authoritative step is a conditional write on the seat rows: UPDATE seat SET status='held', hold_id=$h, hold_expires_at=now()+'6 min', version=version+1 WHERE event_id=$e AND section_id=$s AND seat_id = ANY($seats) AND (status='free' OR (status='held' AND hold_expires_at < now())), committing only if every requested seat matched. The database row locks serialise competing fans. Expired holds count as free in the predicate, so correctness never depends on a cleanup job being on time; the sweeper just keeps the map tidy. Purchase re-checks hold_id and expiry inside its own transaction, so a fan who paid after expiry is refunded rather than double-sold.

The virtual waiting room

The waiting room turns an uncontrolled spike into a steady stream. It lives at the edge and is mostly static: the queue page and position endpoint are cached per queue ticket for a few seconds. Positions come from a Redis sorted set; everyone who joins before opening is shuffled into random order, which removes the incentive to hammer refresh before the start. An admitter advances a pointer by N every few seconds, choosing N from live checkout metrics (holds per second, payment latency, DB CPU). Admitted fans get a signed token bound to account and event, so tokens cannot be shared or replayed elsewhere. If the waiting room fails, the gateway fails closed for that event rather than letting the crowd through.

Seat map reads at scale

Thousands of admitted fans refresh the seat map every few seconds, and each refresh is far more expensive than a hold if it hits the inventory DB. Keep one availability bitmap per section in Redis (one bit per seat), updated from the seat-change stream, and serve maps from it with a short edge cache. The map is allowed to be a few seconds wrong, which is why the hold call returns precise conflicts. A useful trick during the rush is to offer 'best available' selection: the service picks seats for the fan from the bitmap, which spreads load and cuts conflicts compared with everyone clicking the same front-row seats.

Hot partitions and ticket limits

A stadium show is one event, so naive sharding by event puts the whole rush on one database. Sharding by section spreads it across 50–100 shards, and a 2–4 seat hold almost always stays within one section. Per-fan limits cut across sections, so enforce them with a separate quota row incremented with a conditional update in the purchase transaction, or as a soft check at hold time plus a hard check at purchase. For general admission, splitting the section's counter into several sub-counters (each holding a slice of the stock) removes the single hot row.

Failure modes

  • If the inventory shard for a section fails over, holds for that section fail with a retryable error for tens of seconds; other sections keep selling.
  • If the admitter stalls, the queue stops moving but nothing breaks; operators can resume it, and expired tokens limit how many fans are mid-checkout.
  • If the payment provider slows down, holds expire before purchase; raise the hold TTL dynamically or reduce the admission rate.
  • If the seat-map cache is lost, rebuild bitmaps from the inventory DB section by section while the map shows 'loading' rather than stale data.
  • If bots pass the waiting room, per-account limits, token binding and purchase-time checks cap the damage.

Core algorithm in code

import random


class WaitingRoom:
    """Fans who arrive before the sale opens get a random place (no prize for hammering
    refresh); later arrivals queue in arrival order. The admitter drains it at the rate the
    checkout tier can absorb."""
    def __init__(self, opens_at):
        self.opens_at = opens_at
        self.early, self.late = [], []
        self.shuffled = False

    def join(self, fan_id, at):
        (self.early if at < self.opens_at else self.late).append(fan_id)

    def admit(self, n):
        if not self.shuffled:
            random.shuffle(self.early)
            self.shuffled = True
        queue = self.early + self.late
        out, rest = queue[:n], queue[n:]
        self.early, self.late = rest, []
        return out


class SeatInventory:
    """Source of truth for seats. Each seat is available, held (with expiry) or sold."""
    def __init__(self, seat_ids, hold_seconds=300):
        self.state = {s: ("free", None, 0.0) for s in seat_ids}
        self.ttl = hold_seconds

    def _free(self, seat, now):
        st, _, exp = self.state[seat]
        return st == "free" or (st == "held" and exp <= now)

    def hold(self, fan, seats, now):
        if not all(self._free(s, now) for s in seats):       # one conditional update in the DB
            return False
        for s in seats:
            self.state[s] = ("held", fan, now + self.ttl)
        return True

    def purchase(self, fan, now):
        mine = [s for s, (st, f, exp) in self.state.items() if f == fan and st == "held" and exp > now]
        for s in mine:
            self.state[s] = ("sold", fan, 0.0)
        return mine

    def counts(self, now):
        c = {"free": 0, "held": 0, "sold": 0}
        for s in self.state:
            st = self.state[s][0]
            c["free" if self._free(s, now) else st] += 1
        return c


random.seed(1)
seats = ["%s-%d" % (row, n) for row in "ABCDEFGHIJ" for n in range(1, 31)]     # 300 seats
inv = SeatInventory(seats, hold_seconds=300)
print("fan X holds A-1,A-2:", inv.hold("X", ["A-1", "A-2"], now=0.0))
print("fan Y holds A-2,A-3 at the same moment:", inv.hold("Y", ["A-2", "A-3"], now=0.0))
print("fan Y retries after X's hold lapses:", inv.hold("Y", ["A-2", "A-3"], now=301.0))

inv = SeatInventory(seats, hold_seconds=300)
room = WaitingRoom(opens_at=100.0)
for i in range(20000):
    room.join("fan%d" % i, at=random.uniform(0, 160))

now, sales, admitted = 100.0, [], 0
while (room.early or room.late) and inv.counts(now)["sold"] < len(seats):
    for fan in room.admit(100):                    # admit 100 fans per 30 s tick
        admitted += 1
        free = [s for s in seats if inv._free(s, now)]
        if not free:
            continue                               # sold out or all held: told to wait
        want = random.sample(free, min(len(free), random.choice([1, 2, 2, 4])))
        if inv.hold(fan, want, now) and random.random() < 0.8:   # 20% abandon checkout
            sales += [(seat, fan) for seat in inv.purchase(fan, now + 60)]
    now += 30

sold_seats = [seat for seat, _ in sales]
print("admitted:", admitted, "| sold out after %.0f s" % (now - 100), "| final:", inv.counts(now))
print("tickets sold:", len(sales), "| any seat sold twice?", len(sold_seats) != len(set(sold_seats)))
print("abandoned holds came back on sale and were resold; fans never admitted:", len(room.early) + len(room.late))

Trade-offs

  • A waiting room protects the core and adds fairness but costs fans minutes of waiting even when capacity might allow faster entry.
  • Database row holds give strict correctness but cost write throughput on hot sections; Redis-only holds are faster but risk losing holds on failover.
  • Random order for early arrivals is fairer to people with slow connections but feels arbitrary to those who arrived first.
  • Short hold TTLs return abandoned seats quickly but cost fans who need more time to pay.
  • Serving the seat map from a cached bitmap scales reads but costs a few seconds of staleness and some 409 conflicts at hold time.

What a strong answer shows

  • Quantifies the gap between arrival rate and checkout capacity and uses a waiting room to close it.
  • Uses an atomic all-or-nothing conditional write for holds with expiry built into the predicate.
  • Separates the cached, stale-tolerant seat map from the authoritative hold path.
  • Avoids a single hot partition for a big event (shard by section, split counters).
  • Handles payment failures, hold expiry and idempotent purchase.
  • Addresses fairness and bot pressure explicitly.

Mistakes to avoid

  • Letting the full on-sale crowd hit the API and database directly and relying on autoscaling.
  • Using a distributed lock service per seat instead of the database's own conditional writes.
  • Relying on a cron job to expire holds, so a slow job blocks seats from being sold.
  • Reading the seat map from the inventory DB on every refresh.
  • First-come-first-served admission that rewards bots and refresh spamming.

Follow-ups to expect

  • How would you support a presale for verified fans? (A separate queue with codes bound to accounts, admitted before the public queue.)
  • How do you handle general admission with 80,000 standing tickets? (Split counters per section with conditional decrement.)
  • What if a fan's payment succeeds but our purchase commit fails? (Reconcile by idempotency key; refund or complete.)
  • How would you run several big on-sales at once? (Independent queues and admission rates per event, shared capacity budget.)
  • How would you show a fan's estimated wait time? (Position divided by recent admission rate, smoothed.)

Practice next

Next Post Previous Post
No Comment
Add Comment
comment url