# Design Ticketmaster

- Source: https://intervueclub.com/problems/realtime/design-ticketmaster
- Author: Dhananjay Aggarwal
- Category: Real-time Systems
- Difficulty: hard
- Asked at: Meta, Google, Amazon, Booking.com, Airbnb
- Last updated: 2026-07-02

A complete system design walkthrough for designing a ticket booking system like Ticketmaster. Covers the hold/confirm flow, pessimistic vs optimistic locking, distributed hold expiry, virtual-queue admission control for demand spikes, seat-map caching, failure scenarios, and everything an interviewer actually evaluates.

## Problem Statement

> "Design a ticket booking system like Ticketmaster. Users browse an event's seats, pick one, and buy it, and no seat can ever be sold to two people."

That's it. One sentence. The rest is yours to figure out.

---

## Requirements Gathering

This is where the interview actually begins. A strong candidate doesn't start drawing boxes. They ask questions. Not to stall, but because the answers fundamentally change the design.

Here's how a good requirements conversation looks:

---

**Candidate:** "Is this reserved seating with individual seats, or general admission where we just track a count?"

**Interviewer:** "Reserved seating. Each seat is a specific, assignable spot on a seat map."

---

**Candidate:** "Is overselling ever acceptable, even one seat, even under load?"

**Interviewer:** "Never. One seat, one owner. That's a hard constraint."

---

**Candidate:** "Do we hold a seat while the user pays, or go straight from selection to payment? And how long is the hold?"

**Interviewer:** "Hold it. Give them about 10 minutes to complete payment."

---

**Candidate:** "Are we designing for Taylor-Swift-level spikes, hundreds of thousands hitting the same on-sale at once?"

**Interviewer:** "Yes. 500,000 concurrent users when a hot event goes on sale."

---

**Candidate:** "What's the latency budget for completing a booking?"

**Interviewer:** "Confirmation within a few seconds once they submit payment."

---

**Candidate:** "Payment is a synchronous external call during confirm, right? Stripe or similar?"

**Interviewer:** "Yes, an external payment provider, called during the confirm step."

---

**Candidate:** "Out of scope: dynamic pricing, resale/secondary market, refunds?"

**Interviewer:** "Correct, out of scope. Just browse, hold, pay, confirm."

---

## Functional and Non-Functional Requirements

### Functional Requirements

1. Users browse an event and see available seats on a seat map
2. Users select a seat and hold it for up to 10 minutes while paying
3. A held seat is unavailable to everyone else during the hold
4. On successful payment, the seat is confirmed to the buyer
5. If payment fails or the hold expires, the seat returns to inventory
6. Users receive a booking confirmation (ticket, email)

### Non-Functional Requirements

| Requirement | Target |
|---|---|
| Correctness | No seat ever booked by two users, a **hard constraint** |
| Consistency model | **CP**: strong consistency for seat assignment (sacrifice availability if forced) |
| Booking latency | under 3s once payment is submitted |
| Peak load | 500k concurrent users; ~1,000 seat-holds/sec |
| Availability | 99.99% for browsing; booking prioritizes correctness over availability |

**Out of scope:** Dynamic pricing, resale/secondary market, refunds, push-notification ticket delivery

### Capacity Estimates

Work through the math. Don't just state the numbers.

```
Contention, not throughput, is the problem:
  Typical arena ≈ 20,000 seats
  500k users chasing 20k seats → ~25 users competing for each seat
  The system spends its effort REJECTING 24 of every 25 requests correctly

Hold-store working set:
  ~1,000 holds/sec × 600s hold ≈ up to ~10k holds live at once → tiny

Seat-map reads (the real volume):
  500k users refreshing the map every few seconds = ~100k+ reads/sec
  vs ~1,000 writes/sec on the booking path
  → reads dwarf writes by ~100x; they must NOT hit the source-of-truth DB

Booking-path writes:
  ~1,000 holds/sec, short transactions (~5ms) → ~200 TPS steady, bounded per event
```

The number that should make you pause: **~25 users per seat.** This isn't a throughput problem, it's a *correctness-under-contention* problem: the entire design exists to serialize access to each seat so exactly one of those 25 wins, while the 100k/sec of browsing traffic never touches the source of truth.

---

## High-Level Design

Two things drive everything: **serializing writes to each seat** (correctness) and **absorbing a half-million browsers** (the read/queue problem). Keep them separate in your head.

```
                    ┌─ Virtual Queue (admission control) ─┐
[Client] → [API Gateway] → [Booking Service] → [Seat DB: Postgres]  ← source of truth
                                   │                  ↑
                                   ├─→ [Hold Store: Redis, TTL]  (fast hold index)
                                   ├─→ [Payment Service] (external, sync on confirm)
                                   └─→ [Confirmation Service] → email/PDF (async)

[Client] → [Seat-Map Cache: Redis] ← refreshed ~2s from Seat DB   (browse path)
```

**Booking path (write):** Requests pass a **virtual queue** at the gateway so the Booking Service is never stampeded. The Booking Service runs the **hold → pay → confirm** flow against the Seat DB, which is the single source of truth for seat ownership. Redis holds a TTL'd index of active holds for fast checks.

**Browse path (read):** The seat map is served from a Redis cache refreshed every ~2 seconds, 100k+ reads/sec that must never touch the source-of-truth DB.

The key insight: **strong consistency is required only for the seat-assignment transaction, not for anything the user merely looks at.** The booking write is CP and serialized per seat; the seat map is deliberately a little stale, and that's fine: a user who tries to grab a just-taken seat simply gets "seat unavailable, pick another."

---

## Deep Dives

### 1. The Three-Step Booking Flow

Never go straight from "user clicked a seat" to "charge the card." You need a reservation in between, or two users can both reach payment for the same seat. The flow is **browse → hold → confirm**.

**Phase 1: Hold** (must be atomic on the seat)

```
1. User selects seat S1
2. Booking Service atomically claims S1 IF it is 'available':
     set status = 'held', hold_by = user, hold_expires_at = now()+10min
3. If the claim succeeded → also record hold:{S1} in Redis with a 600s TTL
4. Return the hold to the client; the user now has 10 min to pay
5. If the claim failed (someone else holds/booked it) → 409, "pick another"
```

The hold is what lets a user safely enter payment while the seat is guaranteed theirs.

**Phase 2: Confirm** (payment, then commit)

```
1. User submits payment → Booking Service calls the Payment Service (sync)
2. On payment success, in ONE transaction:
     a. re-check the hold is still valid (still held by this user, not expired)
     b. set status = 'booked', booked_by = user
     c. commit, then delete the Redis hold key
     d. enqueue ticket generation + email (async, off the critical path)
3. On payment failure → leave the hold intact; the user can retry within the window
```

The **re-check in 2a** is essential: it closes the rare race where the hold expired (and the seat was released) *while the payment call was in flight*. Never confirm a seat you no longer hold.

---

### 2. Locking Strategy: Pessimistic vs Optimistic

Two users hit "hold S1" at the same millisecond. Exactly one must win. This is the technical core of the interview.

**Pessimistic: `SELECT ... FOR UPDATE`.** Take an exclusive row lock, then decide.

```sql
BEGIN;
SELECT status FROM seats WHERE id = 'S1' FOR UPDATE;   -- other txns block here
-- status == 'available' ?
UPDATE seats SET status='held', hold_by=$user,
                 hold_expires_at = NOW() + INTERVAL '10 min'
WHERE id = 'S1' AND status = 'available';
COMMIT;
```

Concurrent holds on S1 are serialized: the second waits for the first to commit, then sees `held` and loses.

**Optimistic: compare-and-swap on a version (or on `status` itself).**

```sql
UPDATE seats SET status='held', hold_by=$user, version = version + 1
WHERE id = 'S1' AND status = 'available' AND version = $read_version;
-- 0 rows affected → you lost the race → 409
```

No lock held across a round trip; the loser simply retries or gets a `409`.

**Decision:** Pessimistic for the hold. A seat is a *scarce, hot* resource. With ~25 contenders, optimistic CAS means 24 immediate retries per seat, wasting work and adding latency. Pessimistic locking serializes them with a critical section of a few milliseconds, which is cheaper under this contention.

### **For senior interviews ↓** you may not need the lock, and how to avoid deadlocks

**A single conditional UPDATE is already atomic.** `UPDATE seats SET status='held' WHERE id='S1' AND status='available'` is one atomic statement: it's effectively a CAS on `status`, and checking `rows_affected == 1` tells you if you won. You don't strictly need `SELECT FOR UPDATE` for a *single-row* hold; the explicit lock earns its keep only when you must read a value and make a multi-statement decision before writing (e.g. "hold the best available seat in section A"), where you `SELECT ... FOR UPDATE SKIP LOCKED` to grab the first unlocked candidate without blocking.

**`SKIP LOCKED` for "give me any available seat."** For general-admission-style "assign me the best open seat," `SELECT ... WHERE status='available' FOR UPDATE SKIP LOCKED LIMIT 1` lets concurrent requests each grab a *different* unlocked row instead of all piling onto the same one, turning a contention hotspot into parallel throughput.

**Multi-seat orders → deadlocks.** A user buying seats {S5, S9} and another buying {S9, S5} can deadlock if they lock in opposite order. Always acquire seat locks in a **canonical order** (e.g. sorted by seat id) so lock acquisition can't cycle.

**Don't hold a DB lock across the payment call.** The lock lives only for the millisecond-scale hold transaction; the 10-minute reservation is represented by `status='held'` + `hold_expires_at`, *not* by an open transaction. Holding a row lock for 10 minutes while a human types their card number would be catastrophic.

---

### 3. Hold Expiry

A user holds S1 and wanders off. After 10 minutes the seat must return to inventory, reliably, even if a process crashed. Use **two mechanisms**, because each covers the other's failure:

**Redis TTL:** `SET hold:{S1} {user} EX 600`. Fast, cheap, self-expiring; the natural "is this hold still alive?" check.

**Database sweeper:** a job every ~30s runs `UPDATE seats SET status='available', hold_by=NULL WHERE status='held' AND hold_expires_at < NOW()`. This is the *authoritative* release and the backstop for when Redis and the DB drift (a Redis eviction, a crash between the two writes, a network partition).

**The DB is the source of truth; Redis is a fast index. If they disagree, the DB wins.** A Redis key can vanish early (eviction) or linger, but `hold_expires_at` in Postgres is the real deadline, and the sweeper enforces it.

### **For senior interviews ↓** the expire-during-payment race and idempotent release

The nasty race: the sweeper releases S1 at the 10:00 mark, and a *third* user immediately holds and starts buying it, while the original user's payment (submitted at 9:58) is still in flight and now succeeds. Both could think they own S1.

The confirm transaction's **re-check under lock** (deep dive 1, step 2a) is what prevents it: the original user's confirm re-reads the seat inside the transaction, sees it's no longer `held by them` (the sweeper flipped it, the third user re-held it), and **fails the confirm**, at which point you refund/void the just-authorized payment. This is why you *authorize* during confirm and only *capture* after the seat write commits: a lost race becomes a voided authorization, not a charged customer with no seat. Release must also be **idempotent**: the sweeper and an explicit user-cancel can both try to free the same hold, and freeing an already-free seat must be a no-op.

---

### 4. Thundering Herd: Virtual Queue Admission Control

At 10:00:00 sharp, 500k users hit one on-sale. If they all reach the Booking Service at once, it falls over before a single seat is sold. The fix isn't rate limiting (which silently drops users). It's a **virtual waiting room**.

```
1. User arrives → gateway issues a queue token, returns instantly
2. Client shows position: "you are 12,450 in line" (poll or WebSocket push)
3. Booking Service admits from the front of the queue at a controlled rate
4. Admitted user gets a short-lived "you may purchase now" token
5. User completes hold → pay → confirm within a bounded window (e.g. 5 min)
```

**Admission rate:** if the Booking Service safely handles ~1,000 req/sec, admit at ~800/sec to leave headroom. Users see a live ETA, which converts anxious refreshing into an orderly, fair funnel. This is exactly how Ticketmaster and Queue-it work: it turns an uncontrolled stampede into a throttled stream the booking core can actually serve.

### **For senior interviews ↓** fairness, bots, and why the queue must be durable

**Bots game FIFO.** A pure first-come queue rewards scripts that open thousands of connections. Gate *entry* with verified phone/email or a CAPTCHA, fingerprint requests to collapse many entries from one actor, and rate-limit queue joins per identity. The queue is fair only if each human is one entrant.

**The queue itself must survive load and failure.** It's the shield, so it can't be the thing that melts. Back it with something horizontally scalable and durable (Redis sorted set by entry timestamp, or Kafka for strict ordering) so a gateway node dying doesn't drop everyone's place in line.

**Admission ≠ purchase.** Being admitted only grants a *chance*: inventory may sell out while you're in line. Communicate that honestly ("seats may sell out before you reach the front") rather than admitting people to a guaranteed-empty room, which just moves the disappointment later.

---

### 5. Seat-Map Read Path

500k users staring at the seat map, availability changing every second as seats are held and released. This is ~100k+ reads/sec, and it **cannot** hit the source-of-truth Seat DB.

- Cache the whole event's seat map in Redis: `event:{id}:seatmap → compact blob`
- Refresh it from the DB every ~2 seconds (or push deltas on hold/book/release)
- Serve **all** browse traffic from cache; never query Postgres per map view
- Accept ~2s staleness: a user may see a seat as free, try to hold it, and get a `409`, and the UI just says "just taken, pick another"

The strict-consistency requirement applies to the *booking transaction*, not the *display*. Conflating the two, trying to show a perfectly live map to 500k people, is what needlessly couples your read scale to your write correctness.

---

## Data Model

```sql
-- Events (Postgres)
events (
  id              UUID PRIMARY KEY,
  name            VARCHAR NOT NULL,
  venue_id        UUID,
  starts_at       TIMESTAMPTZ,
  total_seats     INTEGER
)

-- Seats: the source of truth for ownership (Postgres)
seats (
  id              UUID PRIMARY KEY,
  event_id        UUID NOT NULL,
  section         VARCHAR,   -- "Floor", "A"
  row             VARCHAR,
  number          INTEGER,
  price_cents     INTEGER,
  status          VARCHAR DEFAULT 'available',  -- available | held | booked
  hold_by         UUID,
  hold_expires_at TIMESTAMPTZ,
  booked_by       UUID,
  booked_at       TIMESTAMPTZ,
  version         INTEGER DEFAULT 1             -- for optimistic path if used
)
-- Index: (event_id, status)          builds the seat map / finds available
-- Index: (status, hold_expires_at)   drives the expiry sweeper

-- Bookings (Postgres)
bookings (
  id            UUID PRIMARY KEY,
  seat_id       UUID UNIQUE NOT NULL,   -- UNIQUE = a seat is booked at most once
  user_id       UUID NOT NULL,
  payment_id    VARCHAR NOT NULL,
  amount_cents  INTEGER,
  created_at    TIMESTAMPTZ
)

-- Hold Store (Redis): hold:{seat_id} → user_id, EX 600  (fast index)
-- Seat-Map Cache (Redis): event:{id}:seatmap → blob, refreshed ~2s
```

The `bookings.seat_id UNIQUE` constraint is the **last line of defense**: even if every layer of application logic somehow failed, the database physically refuses to record two bookings for one seat. Correctness constraints belong in the schema, not only in code.

---

## API Design

```
POST /api/v1/seats/:id/hold
─────────────────────────────────────────────
Authorization: Bearer <token>   (+ valid admission token during on-sale)
Response 200: { "hold_id": "uuid", "expires_at": "2026-07-02T10:10:00Z" }
Response 409: { "error": "seat_unavailable", "message": "This seat was just taken." }

POST /api/v1/holds/:hold_id/confirm
─────────────────────────────────────────────
Request:  { "payment_method_id": "pm_stripe_..." }
Response 200: { "booking_id": "uuid", "confirmation_code": "TM-ABC123" }
Response 402: { "error": "payment_failed", "message": "Card declined." }
Response 410: { "error": "hold_expired", "message": "Your hold has expired." }

GET /api/v1/events/:id/seats        (served from cache, ~2s stale)
─────────────────────────────────────────────
Response 200:
  { "seats": [ { "id": "...", "section": "A", "row": "12", "number": 5,
                 "status": "available", "price_cents": 15000 } ] }
```

Note: identity (`user_id`) comes from the auth token, never a client field. During an on-sale, `/hold` also requires a valid **admission token** issued by the virtual queue; without it, the booking core is exposed to the full stampede.

---

## Failure Scenarios and Edge Cases

### **For senior interviews ↓** what breaks and how the system recovers

**Payment succeeds but the confirm write fails**

You've charged the card and then the DB commit fails, or the seat was lost to the expiry race. Fix the ordering: **authorize** the payment during confirm, write the seat in the transaction, and only **capture** after the commit succeeds. If the seat write fails, void the authorization; the customer is never charged for a seat they didn't get. Make confirm idempotent on `hold_id` so a client retry can't double-book or double-charge.

**Hold expires while payment is in flight**

The sweeper releases the seat at 10:00; the user's in-flight payment returns success at 10:01. The confirm's re-check-under-lock sees the seat is no longer theirs and fails; the authorization is voided. Covered by deep dive 3.

**Redis (hold index or seat-map cache) goes down**

Holds still work: the DB is the source of truth, and the atomic conditional UPDATE plus `hold_expires_at` don't depend on Redis; you lose only the fast hold index and fall back to DB checks. The seat map degrades to a slower/staler view or a brief "refresh" state, but you never oversell, because Redis was never authoritative for ownership.

**Seat DB primary fails**

Booking (writes) pauses until a replica is promoted, which is acceptable under CP: better to stop selling for seconds than to oversell. Browsing continues from cache and read replicas. This is the deliberate availability sacrifice the requirements allow.

**Queue gaming / bots**

Scripts flood the virtual queue to jump the line. Gate entry with verified identity/CAPTCHA, fingerprint to collapse duplicate entries, and rate-limit joins per identity. Covered in deep dive 4.

**Double-submit / user hits confirm twice**

Idempotency key on `hold_id` (or a client-supplied request id): the second confirm returns the same booking rather than attempting a second charge or write.

**Confirmation storm**

A sell-out generates 20k emails/PDFs in minutes. Enqueue them (SQS/Kafka) and process at a sustainable rate; ticket delivery is never on the booking critical path.

---

## Code

### Hold (atomic claim) and Confirm (re-check + commit)

**Java**
```java
public class BookingService {
    private final JdbcTemplate db;
    private final RedisClient redis;
    private final PaymentService payments;

    // HOLD: one atomic conditional UPDATE, claims the seat only if available.
    public HoldResult hold(UUID seatId, UUID userId) {
        int rows = db.update("""
            UPDATE seats SET status='held', hold_by=?,
                             hold_expires_at = NOW() + INTERVAL '10 minutes'
            WHERE id=? AND status='available'
            """, userId, seatId);
        if (rows == 0) return HoldResult.conflict();        // lost the race → 409
        redis.setex("hold:" + seatId, 600, userId.toString());  // fast index
        return HoldResult.held(seatId, Instant.now().plusSeconds(600));
    }

    // CONFIRM: authorize → write seat under re-check → capture.
    public Booking confirm(UUID seatId, UUID userId, String paymentMethod) {
        String authId = payments.authorize(paymentMethod, priceOf(seatId));  // hold funds
        try {
            Booking booking = db.execute(tx -> {
                // Re-check under lock: still held by THIS user, not expired?
                Seat s = tx.query("SELECT * FROM seats WHERE id=? FOR UPDATE", seatId);
                if (!s.status().equals("held") || !userId.equals(s.holdBy())
                        || s.holdExpiresAt().isBefore(Instant.now())) {
                    throw new HoldInvalidException();       // race lost → void auth
                }
                tx.update("UPDATE seats SET status='booked', booked_by=?, booked_at=NOW() "
                          + "WHERE id=?", userId, seatId);
                return tx.insertBooking(seatId, userId, authId);  // seat_id UNIQUE
            });
            payments.capture(authId);                        // charge only after commit
            redis.del("hold:" + seatId);
            enqueueTicket(booking);                          // async email/PDF
            return booking;
        } catch (HoldInvalidException e) {
            payments.voidAuth(authId);                       // never charge without a seat
            throw new HoldExpiredException();
        }
    }
}
```

**Python**
```python
# HOLD: one atomic conditional UPDATE, claims the seat only if available.
def hold(seat_id, user_id, db, redis):
    rows = db.execute(
        """UPDATE seats SET status='held', hold_by=%s,
                            hold_expires_at = NOW() + INTERVAL '10 minutes'
           WHERE id=%s AND status='available'""",
        (user_id, seat_id),
    ).rowcount
    if rows == 0:
        return HoldResult.conflict()                 # lost the race → 409
    redis.setex(f"hold:{seat_id}", 600, str(user_id))  # fast index
    return HoldResult.held(seat_id)

# CONFIRM: authorize → write seat under re-check → capture.
def confirm(seat_id, user_id, payment_method, db, redis, payments):
    auth_id = payments.authorize(payment_method, price_of(seat_id))  # hold funds
    try:
        with db.transaction() as tx:
            s = tx.query_one("SELECT * FROM seats WHERE id=%s FOR UPDATE", (seat_id,))
            if (s.status != "held" or s.hold_by != user_id
                    or s.hold_expires_at < now()):
                raise HoldInvalid()                  # race lost → void auth
            tx.execute("UPDATE seats SET status='booked', booked_by=%s, booked_at=NOW() "
                       "WHERE id=%s", (user_id, seat_id))
            booking = tx.insert_booking(seat_id, user_id, auth_id)  # seat_id UNIQUE
        payments.capture(auth_id)                    # charge only after commit
        redis.delete(f"hold:{seat_id}")
        enqueue_ticket(booking)                      # async email/PDF
        return booking
    except HoldInvalid:
        payments.void(auth_id)                       # never charge without a seat
        raise HoldExpired()
```

**C++**
```cpp
// HOLD: one atomic conditional UPDATE, claims the seat only if available.
HoldResult BookingService::hold(const Uuid& seatId, const Uuid& userId) {
    int rows = db_.execute(
        "UPDATE seats SET status='held', hold_by=?, "
        "hold_expires_at = NOW() + INTERVAL '10 minutes' "
        "WHERE id=? AND status='available'", userId, seatId);
    if (rows == 0) return HoldResult::conflict();            // lost the race → 409
    redis_.setex("hold:" + seatId.str(), 600, userId.str()); // fast index
    return HoldResult::held(seatId);
}

// CONFIRM: authorize → write seat under re-check → capture.
Booking BookingService::confirm(const Uuid& seatId, const Uuid& userId,
                                const std::string& paymentMethod) {
    std::string authId = payments_.authorize(paymentMethod, priceOf(seatId));
    try {
        Booking booking = db_.transaction([&](Tx& tx) {
            Seat s = tx.query("SELECT * FROM seats WHERE id=? FOR UPDATE", seatId);
            if (s.status != "held" || s.holdBy != userId ||
                s.holdExpiresAt < now()) {
                throw HoldInvalid{};                          // race lost → void auth
            }
            tx.execute("UPDATE seats SET status='booked', booked_by=?, booked_at=NOW() "
                       "WHERE id=?", userId, seatId);
            return tx.insertBooking(seatId, userId, authId);  // seat_id UNIQUE
        });
        payments_.capture(authId);                            // charge only after commit
        redis_.del("hold:" + seatId.str());
        enqueueTicket(booking);                               // async email/PDF
        return booking;
    } catch (const HoldInvalid&) {
        payments_.voidAuth(authId);                           // never charge without a seat
        throw HoldExpired{};
    }
}
```

---

## Trade-off Summary

| Decision | Chosen | Alternative | Why |
|---|---|---|---|
| Consistency model | CP (strong) for booking | AP | Overselling is never acceptable; correctness beats availability here |
| Locking | Pessimistic / atomic conditional UPDATE | Optimistic CAS with retries | ~25 contenders per seat make retry storms expensive; serialize instead |
| Reservation | Hold with TTL, then confirm | Selection straight to payment | Guarantees the seat is the user's while they pay; no double-payment races |
| Hold expiry | Redis TTL + DB sweeper | Redis TTL only | DB is source of truth; sweeper backstops Redis drift/eviction |
| Payment | Authorize on confirm, capture after commit | Charge, then write seat | A lost seat becomes a voided auth, never a charge without a seat |
| Demand spikes | Virtual queue | Rate limiting | Fair, visible, and shields the booking core; rate limiting silently drops users |
| Seat-map reads | Cached, ~2s stale | Strong-consistent live map | 100k+ reads/sec can't hit the source of truth; staleness is harmless for browsing |

---

## Follow-up Questions

**Basic**

**Q: Why have a separate hold step? Why not select-then-pay?**

Without a hold, two users can both reach the payment screen for the same seat and both get charged, or the second is rejected after entering card details, terrible UX and a refund mess. The hold reserves the seat the instant it's selected, so payment happens against a seat that's already guaranteed to be theirs for 10 minutes.

**Q: Why pessimistic locking here when most systems prefer optimistic?**

Optimistic locking shines under *low* contention, where retries are rare. Here ~25 users fight for each seat, so optimistic CAS produces 24 wasted retries per seat and a retry storm. Pessimistic locking (or a single atomic conditional UPDATE) serializes contenders with a millisecond critical section, cheaper than the churn.

**Q: Why is a virtual queue better than rate limiting?**

Rate limiting silently rejects requests: users just see errors and hammer refresh, amplifying load. A virtual queue admits everyone eventually, shows a live position/ETA, and throttles the stream reaching the booking core to a rate it can serve. It converts a stampede into a fair, orderly funnel.

**Q: How can you serve a live-ish seat map to 500k people?**

You don't serve a perfectly live one; you serve a ~2s-stale cached map from Redis, refreshed from the DB. Browsing is high-volume and tolerant of slight staleness; the booking transaction is where strong consistency is enforced. A user who grabs a just-taken seat simply gets a `409`.

### **Senior follow-ups ↓**

**Q: A user's card is charged but they didn't get the seat. Walk me through how your design prevents that.**

Payment is two-phase: **authorize** during confirm (funds held, not captured), write the seat inside the transaction with a re-check under lock, and **capture** only after the commit succeeds. If the seat write fails (the hold expired, the sweeper released it, another user re-held it) you **void** the authorization. The customer never sees a charge without a seat. Confirm is idempotent on `hold_id`, so a retry can't double-charge either.

**Q: How do you handle an order for multiple seats atomically, and avoid deadlocks?**

Lock all requested seats in a **canonical order** (sorted by seat id) so two overlapping orders can't acquire in opposite order and cycle. Do the whole multi-seat claim in one transaction: if any seat isn't available, the transaction rolls back and none are held: all-or-nothing, so a user never ends up holding half an order.

**Q: Your Postgres primary can't take the write load for a mega-event. What do you do?**

Seats per event are bounded (~100k), and contention is *within* an event, so shard by `event_id`: each hot event lives on its own primary and can be scaled vertically or isolated onto dedicated hardware for the on-sale. The virtual queue further caps the write rate reaching any one event's DB. Read load is already off the primary via the seat-map cache and replicas.

**Q: How would you make the seat map feel live instead of 2s stale, without coupling it to booking correctness?**

Push deltas instead of polling a cached blob: when a seat flips to held/booked/available, publish a small event that fans out to connected clients over WebSocket (the same presence/pub-sub pattern as a chat system). Clients patch their local map in near-real-time. The map is still just a *view* (booking correctness stays in the CP transaction) but the user sees seats gray out live, which reduces `409`s at hold time.

---

## Minute-by-Minute Interview Playbook

**0 to 5 min: Requirements**
Ask the questions above. Pin down reserved-vs-GA, the hold, the no-overselling hard constraint, and Taylor-Swift-scale demand. Confirm out-of-scope. Write FR/NFR, and write "CP, no overselling" prominently.

**5 to 8 min: Capacity estimates**
Do the math out loud. The ~25-users-per-seat number reframes this as correctness-under-contention, and the 100:1 read:write skew justifies caching the seat map. State them before designing.

**8 to 18 min: HLD**
Draw browse → hold → confirm. Show the Booking Service, Seat DB (source of truth), Hold Store, and the seat-map cache. Explain *why the hold exists* before the interviewer asks.

**18 to 38 min: Deep dives**
Lead with locking: draw two users grabbing S1, show exactly where the race is, and how the atomic conditional UPDATE / `SELECT FOR UPDATE` serializes them. This is the core technical moment. Then the virtual queue for the herd, then dual-mechanism hold expiry (and the expire-during-payment race), then the cached seat map.

**38 to 45 min: Failure scenarios and wrap-up**
Cover authorize-then-capture (no charge without a seat) and the DB-primary-fails CP choice. Summarize in one sentence: "Serialize each seat with an atomic claim under CP, reserve with a TTL'd hold, shield the core with a virtual queue, and serve the map from cache: correctness where it matters, staleness where it doesn't."

**Green flags**
- Identifies the double-booking race immediately and puts the constraint in the schema
- Proposes the hold step before being prompted
- Knows `SELECT FOR UPDATE` / atomic conditional UPDATE and when pessimistic beats optimistic
- Reaches for a virtual queue, not rate limiting, for the spike
- Separates strong consistency (booking) from eventual consistency (seat map)
- Orders payment as authorize→capture so a lost seat is never a charge

**Red flags**
- Application-level "check then set" with no DB-level locking or unique constraint
- No hold step; selection straight to payment
- Treats the demand spike as pure rate limiting
- Ignores hold expiry, or uses Redis TTL alone with no DB backstop
- Uses eventual consistency for the booking transaction
- Charges the card before the seat write commits

---

## Further Reading

- [How a Virtual Waiting Room Works](https://queue-it.com/blog/how-does-a-virtual-waiting-room-work/): The admission-control pattern that shields a booking system from an on-sale stampede: fairness, ordering, and bot mitigation at the front door.

- [PostgreSQL: SELECT ... FOR UPDATE / SKIP LOCKED](https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE): The row-locking primitives behind the hold, including SKIP LOCKED for grabbing any available seat without contention.

- [Stripe: Authorize and Capture](https://stripe.com/docs/payments/place-a-hold-on-a-payment-method): Two-phase payments: authorize during confirm, capture after the seat commits, void on failure, so a customer is never charged without a seat.

- [Related: Design WhatsApp](https://intervueclub.com/problems/realtime/design-whatsapp): Shares the strict-ordering and consistency mindset; its presence/pub-sub push is exactly how you'd make the seat map update live.

- [Related: Design a Rate Limiter](https://intervueclub.com/problems/databases/design-a-rate-limiter): The queue's per-identity join limits and bot mitigation build directly on distributed rate limiting.
