Design a Ticket Booking System
Ticketmaster System Design
Overview
A ticket booking system lets users browse events, pick seats, and pay — like Ticketmaster, SeatGeek, or a movie theater app. The product seems like e-commerce, but it has one strict property ordinary shopping carts do not: a seat is a unique inventory unit that exactly one person may buy. Two users must never both own seat 14C. Demand for a hot event can also arrive as a synchronized spike: hundreds of thousands of people clicking "buy" the instant tickets drop. That creates a concurrency and contention problem that defines the design.
It is "Hard" because the core is correctness under extreme contention: preventing double-booking with seat holds and TTLs, choosing the right locking strategy, surviving the thundering herd on a hot event with a waiting room/queue, making payment idempotent, and deliberately choosing consistency over availability for seat inventory. Browsing can be eventually consistent and cached; buying cannot.
In this walkthrough you'll scope it, size it, model the inventory, design the API, draw the booking path, and examine reservations/locking, the hot-event thundering herd, and idempotent payment.
Functional requirements
- Browse events and view seat availability. Search events; see which seats are free for a chosen event.
- Reserve a seat. Temporarily hold selected seats while the user checks out, so no one else can grab them.
- Book (pay). Complete payment and permanently assign the held seats to the buyer; release the hold if the user abandons or times out.
Out of scope (state it): dynamic pricing internals, recommendations, refunds/resale (a separate flow), and the seat-map rendering UI. We keep holds and payment firmly in scope — they're the whole problem.
Non-functional requirements
- Strong consistency on seat inventory. This is the headline. A seat must be sold at most once; the system must prefer rejecting a booking (or showing "unavailable") over risking a double-sell. For inventory, consistency wins over availability (the CP choice in CAP).
- High availability for browsing. Reading event/seat info should stay up and fast even under load — it can be eventually consistent and heavily cached.
- Handle demand spikes. A hot on-sale can be 10–100× normal load concentrated in minutes; the system must degrade gracefully (queue) rather than collapse.
- No lost money / no charged-but-no-ticket. Payment must be effectively-once via idempotency key + reconciliation (true exactly-once delivery is impossible) — idempotent so retries do not double-charge, and atomic with seat assignment.
Estimations
State assumptions; the aim is to justify the contention design.
Rounded assumptions
- Events at any time: 100K; average venue: 2,000 seats; large venue: 50,000–100,000 seats.
- Normal booking rate: 100 bookings/sec platform-wide.
- Hot on-sale: a 60,000-seat stadium where 500K users try to buy in the first 10 minutes.
- That hot event creates around ~800 booking attempts/sec for one event, but only 60K users can eventually succeed.
- Availability polling during the sale can reach around ~100K reads/sec for that one event.
How the numbers affect the design
| Signal from the numbers | Design decision |
|---|---|
| Attempts greatly exceed available seats | Use a waiting room to control how many users reach booking. |
| One event can create a concentrated spike | Partition inventory by event_id, but protect hot event shards. |
| Seat inventory is small but highly contended | Optimize for correctness, not storage size. |
| Availability reads can reach ~100K/sec | Serve browsing and seat maps from cache/search, not the inventory DB. |
| Payment retries are inevitable | Require idempotency keys and reconciliation. |
Storage
- Per seat: seat id (8 bytes), event id (8 bytes), status (1 byte), price (8 bytes), hold/owner ids (~32 bytes) ≈ ~64 bytes. Even 100K events × 2,000 seats avg = 200M seats × 64 bytes ≈ ~13 GB — small; the inventory fits comfortably in a sharded relational store partitioned by event.
- Bookings and the audit/ledger of holds and payments grow over time but are append-mostly and modest.
Caution
Do not focus only on storage size. The hard problem is not storing seats; it is safely updating the same popular seats under heavy contention.
Core entities / data model
| Entity | Key fields |
|---|---|
Event |
event_id (PK), name, venue_id, start_time, on_sale_time |
Seat |
seat_id (PK), event_id, section/row/number, price, status, held_by, expires_at, version |
SeatHold |
hold_id, seat_id, user_id, created_at — append-only audit trail (optional) |
Booking |
booking_id, user_id, event_id, [seat_ids], payment_id, status |
Payment |
payment_id, booking_id, idempotency_key, amount, status |
Seat.status is the heart of correctness: AVAILABLE → HELD → BOOKED (and HELD → AVAILABLE on expiry/abandon). The version column enables optimistic concurrency. The active hold lives on the seat row itself (held_by + expires_at) so the whole AVAILABLE → HELD transition is a single atomic statement; SeatHold is just an append-only audit trail, not the source of truth. Inventory lives in a strongly consistent relational store (Postgres/MySQL) partitioned by event_id so one event's contention stays on one shard and transactions are local. Browse/search data is denormalized into a cache + search index, decoupled from the transactional inventory.
API design
A RESTful API covers three steps that map straight onto the concurrency design — browse seats, hold them, then book. Holding and booking are deliberately separate so seats aren't locked while a human slowly checks out.
List seats
GET /api/events/{id}/seats
200 OK
Returns: { "seats": [ { "seat_id": "14C", "status": "AVAILABLE", "price": ... }, ... ] } (cached)
List an event's seats and their availability for the seat map. Browsing is read-heavy and tolerates slightly stale data, so this response is cached.
Hold seats
POST /api/events/{id}/holds
Body: { "seat_ids": ["14C", "14D"] }
201 Created
Returns: { "hold_id": "...", "expires_at": "...", "ttl_seconds": 300 } (or 409 Conflict if any seat taken)
Briefly reserve the chosen seats — a hold with a TTL (e.g. 5 minutes) — so the user has time to pay without losing them. Returns 409 Conflict if any requested seat was already taken. This is the step that must be race-free; the seat-reservation deep dive covers how.
Book (confirm payment)
POST /api/bookings
Header: Idempotency-Key: <client-generated-uuid>
Body: { "hold_id": "...", "payment_token": "..." }
201 Created
Returns: { "booking_id": "...", "status": "CONFIRMED" }
Confirm payment and convert the hold into a booking. The Idempotency-Key header is mandatory: a network retry of the same booking must not double-charge the card or double-book the seats — the server returns the original result for a repeated key.
High-level architecture
Two paths matter: a browse path (read seat availability) and a booking path (hold → pay → confirm), plus a background worker that expires abandoned holds. We'll walk each, then combine them.
1. Browse path
Reading the seat map is high-volume and tolerates slight staleness, so it is served from cache.
flowchart LR
Client[Client] -->|"browse seats"| Cache[(Availability Cache)]
Cache -->|"hit: seat statuses"| Client
Cache -.->|"miss"| InvDB[(Inventory DB - per event)]
- A browse request reads seat availability from the cache.
- On a miss it falls back to the inventory DB — but at scale the cache absorbs the read storm, so browsing never overloads the transactional store.
Note
Browse availability is a hint, not a reservation. Even with perfectly live data, the seat can be taken in the gap between the map rendering and your click, so freshness can never close the race. The seat becomes truly yours only at the hold step, which runs the authoritative strongly-consistent check (deep dive 1). A stale "available" simply turns into "just taken — pick another" there, never a double-sell. That is exactly why browsing can be cheap and cached while only inventory is strongly consistent.
Live seat map. Having every viewer poll for changes at this scale is wasteful, so push instead: keep an open Server-Sent Events (SSE) stream per viewer and emit a small message whenever a seat changes state, so the map greys out seats as others grab them — no refresh needed. SSE is one-directional (server → client), which is all a seat map needs. This shrinks the stale window behind the hint above but cannot erase it: in a Taylor-Swift-scale sale the whole map can flip to taken within seconds, so the hold step stays the source of truth.
2. Booking path
Booking is the strongly-consistent path: an optional waiting room throttles the herd, then hold → pay → confirm.
flowchart LR
Client[Client] -->|"hot on-sale"| WR[Waiting Room / Queue]
WR -->|"admit in order"| Booking[Booking Service]
Client -->|"normal load"| Booking
Booking -->|"reserve seat: HELD + TTL (atomic)"| InvDB[(Inventory DB)]
Booking -->|"charge (idempotent)"| Pay[Payment Service]
Pay --> PSP[(Payment Provider)]
Booking -->|"on success: mark BOOKED"| InvDB
- During a hot sale, users enter a waiting room that admits them at a rate the inventory DB can handle (normal load skips it).
- The Booking Service atomically transitions the chosen seats
AVAILABLE → HELDwith a TTL — the step that prevents double-booking (deep dive 1). - The user pays while the hold is valid; payment is idempotent so a retry can't double-charge (deep dive 3).
- On success, the seats transition
HELD → BOOKED.
3. Combined architecture
Putting it together — note the background expiry worker that returns abandoned holds to AVAILABLE:
flowchart LR
Client[Client] --> GW[API Gateway]
GW -->|"browse seats"| Cache[(Availability Cache)]
Cache -.->|"miss"| InvDB[(Inventory DB - per event)]
GW -->|"hot on-sale"| WR[Waiting Room / Queue]
WR -->|"admit in order"| Booking[Booking Service]
GW -->|"normal load"| Booking
Booking -->|"reserve seat: HELD + TTL (atomic)"| InvDB
Booking -->|"charge (idempotent)"| Pay[Payment Service]
Pay --> PSP[(Payment Provider)]
Booking -->|"on success: mark BOOKED"| InvDB
Expiry[Hold-Expiry Worker] -->|"release expired holds -> AVAILABLE"| InvDB
InvDB -.->|"publish status change: invalidate / write-through"| Cache
The dotted Inventory DB → Availability Cache edge is how browsing stays fresh: every committed status change (HELD, BOOKED, or an expiry worker releasing back to AVAILABLE) is pushed to the cache — invalidate the key, or write the new status through — so reads reflect the truth within milliseconds instead of waiting for a TTL. The short TTL on the cache is just a backstop in case an invalidation event is missed; it isn't the primary freshness mechanism.
Tip
Separate browsing from booking. Browsing can be cached and eventually consistent; booking must use the strongly consistent inventory path.
Deep dives
1. Seat reservation without double-booking
The core invariant: a seat is sold at most once. The mechanism is a hold with a TTL plus an atomic state transition; the question is how you make that transition safe under concurrency. Below are three common approaches, ordered by how much contention they remove. They aren't strictly "worse to better"; each fits a different scale. Optimistic concurrency is the sensible default, with the Redis-based option reserved for when hold traffic would overwhelm the database.
Method 1 (works, with caveats — simple, low scale) — Pessimistic locking (SELECT ... FOR UPDATE)
This locking happens only at the hold step — when the user clicks to select seats — never while browsing. Inside one database transaction, lock the seat's row as you read it, so the check ("is it still AVAILABLE?") and the flip to HELD are one atomic unit that no other transaction can interleave with; then commit (the lock releases on commit). In SQL that lock-as-you-read is SELECT ... FOR UPDATE: the first transaction to reach the row holds it exclusively, so two users clicking the same seat at the same instant are serialized, not raced — one wins, the other blocks, then re-reads HELD and loses cleanly (see the trace below). The race only appears if you read with a plain SELECT and lock later; locking as you read is what closes it.
Two buyers race for seat 14C:
T1: BEGIN; lock 14C (FOR UPDATE) -- T1 now holds the row lock
T2: BEGIN; lock 14C → blocks, waiting for T1
T1: 14C is AVAILABLE → set HELD; COMMIT -- lock released
T2: unblocks, re-reads 14C → now HELD → reject
→ exactly one buyer wins; the other waits briefly, then loses cleanly
- Pro: simple to reason about; the lock guarantees no two transactions both see "available." Correct by construction.
- Con: locks held during the transaction serialize all contenders for the same seat, and if you hold the lock across the user's payment you'd lock seats for minutes — disastrous. So you lock only for the brief hold transition, not through payment. Under a hot-event herd, many contenders pile up waiting on the same hot rows, increasing latency and risking lock contention/deadlocks if multiple seats are locked out of order (lock seats in a consistent order to avoid deadlock).
- Verdict: works, with caveats — correct and common at low scale, but keep the lock window tiny (just the status flip), never across payment, and don't lean on it for hot rows.
What the other buyer experiences. With plain FOR UPDATE, a second buyer racing for the same seat blocks, then unblocks to find it HELD and loses cleanly (the wait is capped by a lock-wait timeout). Two variants change that response: SELECT ... FOR UPDATE NOWAIT fails them instantly — better UX than a spinner when you want an immediate "that seat's taken, pick another" — and SELECT ... FOR UPDATE SKIP LOCKED skips rows another transaction has locked, which is ideal for "assign me any available seat" and for worker queues that pull distinct rows without fighting over the same one.
Caution
Do not hold a database lock across the user's payment flow. Payment can take seconds or minutes, and holding locks that long will block other buyers.
Method 2 (default choice) — Optimistic concurrency control (version / CAS)
Read the seat together with a version number. To hold it, do a conditional update (a compare-and-swap, or CAS): flip it to HELD only if it's still AVAILABLE and the version still matches what you read, bumping the version as you go. Exactly one racer's update matches and succeeds; everyone else updates 0 rows — they lost the race, so tell them the seat's gone. The predicate also matches an expired hold so a stale HELD seat is reclaimable in the same statement — in SQL: UPDATE seat SET status='HELD', version=version+1 WHERE id=? AND (status='AVAILABLE' OR (status='HELD' AND expires_at < now())) AND version=?.
Two buyers race for seat 14C (AVAILABLE, version 7):
both read 14C → status=AVAILABLE, version=7
T1: UPDATE ... SET HELD, version=8 WHERE id=14C AND status=AVAILABLE AND version=7 → 1 row ✓
T2: UPDATE ... WHERE ... version=7 → 0 rows (version is already 8) → "seat taken"
→ no locks held; exactly one UPDATE matches, so no double-sell
An expired hold is reclaimed the same way — the extra expires_at < now() branch is what lets the CAS fire on a stale HELD row, and the version check still serializes racers so only one wins:
Two buyers reclaim seat 14C (stale HELD, expired, version 9):
both read 14C → status=HELD, expires_at in the past, version=9
T1: UPDATE ... SET HELD, version=10 WHERE id=14C AND (... OR (status=HELD AND expires_at<now())) AND version=9 → 1 row ✓
T2: UPDATE ... version=9 → 0 rows (version is already 10) → "seat taken"
→ the expired hold is reclaimed by exactly one buyer; no cron job needed
- Pro: no locks held; great throughput when contention is low (most seats in most events). The single conditional
UPDATEis itself atomic, so no double-sell. - Con: under high contention on the same hot seats, many updates fail and users must retry — wasted work and a poor UX of "seat taken." For genuinely hot seats, pessimistic or a queue handles the herd better.
- Verdict: the right default for the common low-contention case; combine with a waiting room to reduce the number of users reaching the hot rows at once.
Releasing an expired hold: nothing flips on its own — the row still reads HELD, but the CAS above matches an expired hold via its status='HELD' AND expires_at < now() branch (the lazy check described after these methods), so an abandoned hold is reclaimed the moment someone next tries the seat. A small background job optionally rewrites such rows back to AVAILABLE just to keep the seat map accurate.
Warning
Do not rely only on optimistic retries during a hot sale. If thousands of users fight for the same seats, most updates fail and the user experience becomes repeated "seat taken" errors.
Method 3 (high throughput) — Distributed lease in Redis with a TTL
Keep the transient hold outside the main database, in Redis. The lock key is built from the seat's identity — hold:{event_id}:{seat_id} (e.g. hold:42:14C) — so everyone contending for the same seat computes the same key; the value stores who holds it. When a user selects a seat, try to set that key with a time-to-live equal to the hold window; if it already exists, someone else holds it. The "set only if absent" check and the expiry are a single atomic command, so there is no race and no cleanup job — the lock disappears on its own when the TTL elapses.
Two buyers race for seat 14C in event 42 (hold window = 5 min):
T1: SET hold:42:14C alice NX EX 300 → OK (key created, T1 holds it)
T2: SET hold:42:14C bob NX EX 300 → (nil) (key already exists) → "seat taken"
T1 pays → mark 14C BOOKED in DB, DEL hold:42:14C
T1 abandons → after 300s the key auto-expires → seat free again
NX = set the key only if it does not already exist (the atomic "win the seat" check); EX 300 = expire after 300 seconds (the automatic hold release). The inventory table then needs only AVAILABLE/BOOKED; the transient HELD state lives entirely in Redis. Grab multiple seats with one Lua script so a partial multi-seat hold can't happen. The Redis hold and the DB BOOKED row live in two systems with no shared transaction, so treat the Redis hold as advisory and the DB's guarded conditional AVAILABLE → BOOKED update (in one DB transaction) as the final authority — a crash between that DB write and the DEL hold:... just leaves a stale key the TTL cleans up (harmless), and it is the DB conditional update, not the Redis key, that actually prevents double-booking.
- Pro: acquiring and releasing a hold is a single in-memory operation — the fastest option under extreme concurrency — and it moves hold contention off the transactional database. Expiry is automatic via the TTL, with no cron lag.
- Con: the seat-map/read path must now also consult Redis to show held seats (an extra moving part); a Redis outage degrades holds; and a TTL that expires during payment needs the same re-check guard as the database approaches. On the outage: the DB's conditional
AVAILABLE → BOOKEDupdate still prevents double-booking, but with holds living only in Redis it does not prevent double-charging — two buyers can both pay concurrently, and one then fails theBOOKEDCAS and must be refunded. The Redis hold is what normally prevents that by giving one buyer an exclusive reservation window; during a Redis outage you lose that window, so a seat can be lost mid-checkout. - Verdict: great when hold traffic would overwhelm the database or you need sub-millisecond holds — at the cost of running Redis as a second source of truth for the transient hold.
Releasing an expired hold: automatic — when the TTL elapses Redis deletes the key itself, and since "no key" is "available" there is no status to flip back. The database stays the source of truth for AVAILABLE/BOOKED and the booking record; Redis holds only the disposable HELD state, so a Redis restart costs at most some in-progress holds (users re-pick), never a confirmed sale.
Whichever method you choose, the hold is the key idea: flip to HELD with an expires_at and give the user a TTL (e.g. 5 minutes) to pay. Make expiry lazy: the reserve check should treat a seat as free when it is AVAILABLE or its hold has already expired — WHERE status='AVAILABLE' OR expires_at < now() (store expires_at on the seat row so this stays a single statement). This lazy-expires_at check is the DB-hold methods' (1–2) mechanism, where HELD/expires_at live on the seat row; Method 3's equivalent is the Redis key TTL itself, so the DB there only tracks AVAILABLE/BOOKED. That way an expired hold is instantly reservable and correctness never depends on a cleanup job running on time. A background expiry worker (or a TTL'd record in Redis) then only resets stale rows for tidy data and an accurate seat map — not for correctness. The hold decouples "I'm choosing this seat" from "I've paid," so seats aren't locked during slow human checkout, yet can't be stolen mid-checkout.
Caution
Do not forget hold expiry. Even with the lazy check above you still need an expires_at on every hold — without one, abandoned carts make the event look sold out and the seat map never frees up.
| Method | How it wins the seat (main difference) | Pros | Cons |
|---|---|---|---|
| 1. Pessimistic lock (low scale) | Lock the row, then flip — SELECT ... FOR UPDATE; hold lives in the DB |
Simplest; correct by construction | Serializes contenders; deadlock risk; doesn't scale on hot rows |
| 2. Optimistic CAS (default) | Lock-free conditional UPDATE guarded by version/status; hold lives in the DB |
No locks; high throughput at low contention; one source of truth | Retries thrash under hot-seat contention |
| 3. Redis lease (high throughput) | Atomic SET NX EX key per seat; hold lives in Redis, auto-expires via TTL |
Fastest; takes hold load off the DB; expiry is automatic | Second source of truth; read path must merge DB + Redis |
2. Thundering herd on a hot event — the waiting room
When 500K users hit one on-sale simultaneously, letting them all reach the inventory DB can overload it (~800 attempts/sec, where ~88% of attempts will fail). The fix is a virtual waiting room / queue:
- On-sale opens; arriving users are placed in a queue (a position token) instead of hitting the booking path directly. The UI shows "you're in line, position N."
- The system admits users at a controlled rate the inventory layer can handle — drip them from the queue into the actual seat-selection/booking flow. This converts an uncontrolled spike into a steady, bounded stream.
- Browsing/availability for queued users is served entirely from cache (100K reads/sec for one event), never the transactional DB.
- Admission can be FIFO (fair) or randomized (anti-bot). Admitted users get a time-boxed session to pick and pay.
Concretely: the queue is a Redis sorted set ordered by arrival time; a worker pops the front at the rate inventory can handle and writes an admission token (e.g. an entry in an admitted:{event_id} set with its own TTL) that the Booking Service checks before accepting any hold — so a user can't skip the line by calling the booking API directly. The user's live position and estimated wait are pushed to the browser over the same SSE channel as the seat map.
The waiting room is what makes consistency-over-availability survivable: rather than failing 440K users with errors, you queue them, admit at a sustainable pace, and most see "sold out" gracefully once inventory is exhausted. It also throttles the hot rows so the locking strategy above isn't overwhelmed.
Warning
Do not let every hot-sale user reach the inventory database at once. The waiting room converts an uncontrolled spike into a controlled stream.
3. Idempotent payment and atomic confirmation
Payment is where money and correctness collide. Two failure modes to defend against: double-charging on retry, and charged-but-no-seat (or seat-but-no-charge).
- Idempotency key: the client generates a unique
Idempotency-Keyper booking attempt and sends it on the booking/payment request. The server stores the key with the result of the first attempt; any retry with the same key returns the original result instead of charging again. Network timeouts make clients retry constantly — without this, one purchase becomes two charges. The payment provider's own idempotency keys add a second layer. - Atomicity of pay + assign: confirm the booking and flip seats
HELD → BOOKEDin a way that can't half-complete. Within one DB you can do the seat-status update and booking record in a transaction. The external charge can't join that DB transaction, so order it carefully: hold seats → charge → on success mark BOOKED; if the charge succeeds but the BOOKED write fails, a reconciliation job repairs it: a background sweep that compares your records against the payment provider's and fixes any mismatch — here, finishing the BOOKED write or refunding — matched by payment id. Many systems model this as a short saga: reserve → pay → confirm, with compensation (refund + release hold) if a later step fails. - Hold expiry vs payment race: if a hold's TTL expires while payment is in flight, you risk charging for a released seat. Guard the confirm step by re-checking the hold is still valid (and owned by this user) inside the same transaction that marks BOOKED; if it expired, fail and refund. Be clear about what this guard does: it detects-and-refunds, it does not prevent the charge. The charge happens before the BOOKED transaction and is external, so it can't join that transaction — if the seat was reclaimed after the charge, the guard catches it and issues a refund rather than stopping the charge. That gap is exactly why reconciliation/refund exists.
Caution
Do not make payment non-idempotent. Network timeouts cause retries, and retries without idempotency can charge the user twice.
Tradeoffs & bottlenecks
- Consistency over availability (for inventory). You deliberately choose CP for seats — better to reject/queue than to double-sell. Browsing stays AP (cached, eventually consistent). Naming this split is the central tradeoff.
- Pessimistic vs optimistic locking. Locks are simple and safe but serialize hot rows; optimistic is fast at low contention but thrashes on hot seats. The waiting room reduces contention enough to make optimistic viable; pessimistic remains a safe fallback for the hottest rows.
- Hold TTL length. Too short and users lose seats mid-checkout; too long and inventory is locked up by abandoners, hurting sell-through. Tune to real checkout times (a few minutes).
- Waiting-room fairness vs throughput. FIFO is fair but bots game it; randomization resists bots but feels unfair. Admission rate trades user wait time against backend safety.
- Hot-event single-shard hotspot. Partitioning by
event_idkeeps a sale's contention on one shard — that shard becomes hot during an on-sale. The waiting room and caching are what keep it from tipping over. - Saga complexity. Making pay+assign effectively-once across an external provider needs idempotency keys and reconciliation (exactly-once delivery is impossible) — more moving parts, but the alternative (lost money or lost seats) is unacceptable.
Extensions if asked
If the interviewer wants to push beyond the core booking flow, add only the extension that changes the design discussion:
- Rate limits and bot control. Add per-user/IP limits before the waiting room.
- Availability caching and CDN. Serve browse traffic from cache while protecting transactional inventory — long TTLs (or a CDN) for near-static event/venue/performer data, short TTLs for volatile seat availability.
- Event search and discovery. Searching events by name, performer, or date with typo tolerance is its own subsystem: index events in a search engine built on an inverted index (e.g. Elasticsearch) for fast full-text and fuzzy matching, kept in sync from the database via change data capture (CDC), with hot queries cached. This is separate from the booking-correctness core this guide focuses on.
- Notifications. Notify users when tickets become available or a hold is about to expire.
What interviewers look for & common mistakes
What interviewers usually reward:
- Holds with a TTL as the mechanism that decouples seat selection from slow human payment without allowing theft.
- A correct anti-double-booking transition — a conditional atomic update (optimistic CAS) or a brief
FOR UPDATElock, and never locking across payment. - A waiting room/queue for the thundering herd, justified by the attempts-≫-inventory math.
- Idempotent payment via an idempotency key, and atomic/saga confirmation of pay + seat assignment with reconciliation.
- Explicitly choosing consistency over availability for inventory while keeping browse paths cached and available.
Before you finish, do a quick mistake check:
- Did you treat seats as single-sale inventory, not a generic cart item?
- Did you avoid holding database locks across payment?
- Did you add a waiting room for hot events?
- Did you include hold expiry for abandoned carts?
- Did you make payment idempotent and reconcile charged-but-not-booked failures?
- Did you choose consistency over availability for seat inventory?
Practice this live
Run this exact question with our AI voice interviewer and get feedback.
Start the interview →