IntervueClubbeta
Continue where you left off?

Reading long content?

Light mode reduces eye strain for sustained reading sessions.

🧠 You've been reading for 20 minutes

A short break now helps your brain consolidate what you just read. You'll retain more.

🌙 Reading late?

Your brain absorbs less after midnight. Consider bookmarking this and coming back fresh — you'll get more out of it.

/ to search·TOC on the right to jump sections·dark/light toggle in nav

Design Ticketmaster

hardReal-time Systemsdistributed-lockingconcurrencybookingconsistencyqueuingredis
Jul 2, 2026·~23 min read
Asked atMetaGoogleAmazonBooking.comAirbnb
Watch Video Walkthrough
Watch the author walk through the problem step-by-step

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#

RequirementTarget
CorrectnessNo seat ever booked by two users, a hard constraint
Consistency modelCP: strong consistency for seat assignment (sacrifice availability if forced)
Booking latencyunder 3s once payment is submitted
Peak load500k concurrent users; ~1,000 seat-holds/sec
Availability99.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.

code
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.

code
                    ┌─ 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)

code
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)

code
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 and another buying 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.

code
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#

code
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
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();
        }
    }
}

Trade-off Summary#

DecisionChosenAlternativeWhy
Consistency modelCP (strong) for bookingAPOverselling is never acceptable; correctness beats availability here
LockingPessimistic / atomic conditional UPDATEOptimistic CAS with retries~25 contenders per seat make retry storms expensive; serialize instead
ReservationHold with TTL, then confirmSelection straight to paymentGuarantees the seat is the user's while they pay; no double-payment races
Hold expiryRedis TTL + DB sweeperRedis TTL onlyDB is source of truth; sweeper backstops Redis drift/eviction
PaymentAuthorize on confirm, capture after commitCharge, then write seatA lost seat becomes a voided auth, never a charge without a seat
Demand spikesVirtual queueRate limitingFair, visible, and shields the booking core; rate limiting silently drops users
Seat-map readsCached, ~2s staleStrong-consistent live map100k+ 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 409s 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
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
The row-locking primitives behind the hold, including SKIP LOCKED for grabbing any available seat without contention.
Stripe: Authorize and Capture
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
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
The queue's per-identity join limits and bot mitigation build directly on distributed rate limiting.

Try a different category