Prompt and Applicable Context
Design a ticket booking system for concerts, sporting events, or high-demand exhibitions. One event has 50,000 assigned seats. Two million users arrive in the first five minutes of the sale, and edge traffic peaks at 50,000 requests per second. The system admits no more than 10,000 concurrent seat-map shoppers and handles up to 2,000 hold attempts and 1,000 payment authorizations per second. A hold lasts five minutes; a held seat that is not purchased must become sellable again.
Assume the payment provider supports separate authorization and capture. The seat-map p99 target is below 500 milliseconds, hold creation below 300 milliseconds, and order-status reads below 200 milliseconds. The arrival volume, latency targets, hold duration, and payment rate are interview assumptions, not published benchmarks for any ticketing company. The core correctness rule is that one seat at one event belongs to at most one valid sold order. When availability degrades, the service may pause admission or reject holds; it must not guess inventory and continue selling.
The scope includes event browsing, a virtual waiting room, the seat map, atomic multi-seat holds, checkout, pre-issuance confirmation, expiry, and recovery. Event creation, dynamic pricing, secondary resale, refund accounting, and a complete bot-detection system are out of scope, but the answer should define how they meet the inventory and payment boundaries. Public 2026 system-design material still presents ticket booking as a combined concurrency and flash-crowd problem. Ticketmaster's public material also describes a real flow in which buyers enter a virtual waiting room and reach seat selection and checkout at a controlled rate.
What the Interviewer Evaluates
The first signal is whether the candidate separates queue fairness from inventory correctness. A virtual waiting room protects the origin and controls entry into the sale. It cannot stop two admitted buyers from requesting the same seat. The authoritative inventory transaction must provide final exclusion.
The second signal is the distinction between a database row lock and a business hold. A PostgreSQL row lock belongs inside a short transaction and is released when the transaction ends. Keeping a connection and transaction open for the five minutes in which a buyer enters details and pays is unsafe. The longer business meaning must be durable state with a holdid and expiresat.
The third signal is time- and retry-aware state transitions. A delayed expiry worker must not release a seat that a new buyer now holds or that has been sold. A client timeout must not create a second hold or payment. Every transition checks current state, owner, version, and deadline, while an idempotency key retrieves the original result.
The fourth signal is a correct payment boundary. Authorization may succeed while local confirmation fails; a sold transaction may commit while its response is lost; capture may succeed before its webhook arrives. Strong answers preserve the order and payment-operation records and converge through lookup, webhooks, and reconciliation. They do not treat a missing response as failure.
Finally, capacity and verification must address the actual bottleneck. CDN, caching, and read replicas can absorb browsing. Seat writes for one hot event should still reside on one transactional authoritative shard. Splitting the seats of one event across write shards too early turns an ordinary three-seat hold into a distributed transaction.
Questions to Clarify Before Answering
- Are seats assigned or general admission? Assigned seating needs exclusive state per seat. General admission is better modeled as an available quantity per ticket tier with a conditional decrement that cannot go negative.
- Must a multi-seat request be all-or-nothing? This prompt requires all-or-nothing holds of one to six seats. Partial success changes pricing, release behavior, and the buyer experience.
- What is the queue's fairness policy? FIFO rewards earlier arrival; random release reduces the effect of millisecond-scale network differences. The policy must be disclosed and remain stable rather than changing mid-sale and still claiming ordered fairness.
- Can payment authorization and capture be separated? If yes, authorize, confirm the seats, then capture. Immediate capture requires compensation when money is taken but seat confirmation fails.
- What is the per-account ticket limit? Enforce it at both hold and order confirmation. A browser-only restriction is bypassable.
- May several regions write the same event? The base design assigns one write home to each event and serves browsing and queueing elsewhere. Concurrent multi-region writes require cross-region consensus or preallocated inventory and change both latency and failure behavior.
- How fresh must the seat map be? If a few seconds of staleness are acceptable, use a snapshot plus deltas. Every displayed
AVAILABLEstate remains advisory until the hold transaction succeeds. - What if a hold expires while payment authentication is still running? Define a single bounded
PAYMENT_PENDINGgrace period. Do not renew forever or resell while the payment result is unknown.
30-Second Answer Framework
“I would keep the two million arrivals in an event-scoped virtual waiting room and issue short-lived signed admission tokens only at a rate supported by the inventory shard, payment provider, and ticket issuer. Browsing and the seat map use CDN, caching, and versioned deltas; hold writes go to the event's single authoritative database shard. A short transaction locks selected seats in sorted order and writes one hold_id with a database-time five-minute expiry only if every seat is available or expired. No database lock remains open during checkout. A delayed queue accelerates expiry cleanup, but release still conditionally matches the hold, state, and deadline. Payment uses a stable operation ID to authorize first. After authorization, a transaction revalidates the hold and commits the seats, order, and outbox as sold before capture. Any timeout becomes unknown and converges through webhooks, lookup, and reconciliation. I would prove the design with same-seat contention, expiry/payment races, lost responses, duplicate messages, and shard failures.”
Step-by-Step Deep Dive
Step 1: Convert the volume into an admission-control target
Two million arrivals over 300 seconds average about 6,667 per second. The 50,000-request-per-second edge peak shows that the opening instant and browser refreshes are much burstier than the average. Inventory is budgeted for only 2,000 hold attempts and 1,000 payment authorizations per second, so edge retries cannot reach the inventory service directly.
Each hold can contain up to six seats, so 2,000 requests per second can cause as many as 12,000 seat-row decisions per second before conflicts and rollbacks. Capacity tests need a skewed hot-section distribution; spreading those 12,000 decisions evenly across 50,000 seats would hide contention.
The virtual waiting room is isolated by eventid and can accept visitors before the sale. It records an arrival cohort, entry time, account, and risk signals. When admitted, a visitor receives a short-lived, signed admissiontoken bound to the event and account. The sale entry verifies the token, expiry, and one active session; invalid traffic is rejected at the edge. Admission rate responds to inventory lock wait, hold p99, payment errors, active shoppers, and completion rate. When the database slows down, reduce admission instead of hiding write contention behind more read replicas.
Cloudflare's public waiting-room documentation describes FIFO ordering from the timestamp at which a visitor first reaches an active queue and a random mode that selects waiting visitors. These are product policies, not database consistency mechanisms. Even FIFO needs explicit rules for time granularity, reconnects, multiple devices, clock authority, and bots. It cannot promise absolute request-by-request order across a global network.
Step 2: Separate browsing, the seat view, and authoritative inventory
Event details, the static venue map, and pricing explanations can live in a CDN or cache. The seat view combines a versioned snapshot with status deltas. A client first receives a snapshotversion, then applies changes containing a seatid, new status, and higher version. It reloads the snapshot after a disconnect or version gap. If 10,000 active shoppers poll every five seconds, they generate about 2,000 reads per second. Delta delivery reduces repeated full-map reads, but it does not become the inventory source of truth.
The seat map may be briefly stale. A buyer can see AVAILABLE and still lose the hold race. A cache cannot confirm a sale or accept a hold from stale data while the database is unavailable. This separates a highly available browsing path from a strongly consistent inventory write path.
Assign every event to one primary write shard and use relational transactions within it. Different events scale horizontally by event_id, while a very hot event may receive a dedicated shard or resource pool. Fifty thousand seat rows are not the capacity problem. Lock waits, connection exhaustion, and retry storms caused by many requests targeting the same few seats are.
Step 3: Define the API, state, and invariants
The core API can stay small:
POST /events/{eventId}/holds
{ seatIds, idempotencyKey, admissionToken }
-> { holdId, status, expiresAt, seats }
POST /holds/{holdId}/checkout
{ paymentMethodToken, idempotencyKey }
-> { orderId, paymentStatus, nextAction }
GET /orders/{orderId}
-> { orderStatus, paymentStatus, seats }A minimal data model is:
SeatInventory(event_id, seat_id, price_version, status,
hold_id, hold_expires_at, order_id, version)
Hold(hold_id, event_id, account_id, status, expires_at,
idempotency_key, request_digest, created_at)
Order(order_id, hold_id, account_id, amount_minor, currency,
status, payment_operation_id, created_at)
PaymentOperation(operation_id, order_id, provider_reference,
kind, status, idempotency_key, updated_at)
Outbox(event_id, aggregate_id, event_type, payload, published_at)The critical invariants are:
one (event_id, seat_id) has at most one valid SOLD order
all seats in one hold share the event, account, and expiry
only the current hold_id may move HELD to SOLD or release it
order confirmation revalidates price version, quantity, limit, and total
one idempotency key describes one immutable request; changed parameters are rejectedMoney is an integer in the currency's minor unit. A client-submitted total is not authoritative; the server recalculates it from the locked price version. An admission_token grants entry to the sale, not ownership of any seat.
Step 4: Create an atomic multi-seat hold with a short transaction
One request selects at most six seats. The transaction sorts by seatid, then locks the rows with SELECT ... FOR UPDATE in that fixed order, reducing deadlocks caused by opposite lock orders. If every row is AVAILABLE or an expired HELD, the transaction writes the same holdid and expiresat = databasenow + 5 minutes to all rows and inserts the Hold plus outbox event. If one row is sold or belongs to another unexpired hold, the whole transaction rolls back.
A conditional UPDATE with status and deadline predicates is another valid implementation, provided the service checks that the updated row count equals the requested count. In either approach, the decision and write must share one authoritative transaction. Acquiring a Redis lock and writing the database asynchronously creates two sources of truth when one succeeds and the other fails. Reading “available” and then writing unconditionally has the original race.
PostgreSQL documents that row locks block writers and lockers on the same rows and are released at transaction end. The buyer's five-minute checkout is therefore durable data state, not a five-minute database transaction. Commit and release the connection immediately, then open a new short transaction for each later transition.
Commit the idempotency record with the hold result. If the service commits the hold and crashes before replying, a retry with the same idempotencyKey returns the original holdId. A new key enters a new race. Store a request digest so the same key cannot be reused for a different group of seats.
Step 5: Make expiry correct even when jobs are late
Generate holdexpiresat from database time. A read can display an expired hold as available, but reclaiming it still checks the deadline in the write transaction. Creating a hold schedules a delayed message, and a periodic scan provides compensation. Both accelerate an explicit transition back to AVAILABLE; neither is the sole source of expiry correctness.
The release operation must resemble:
UPDATE seat_inventory
SET status = 'AVAILABLE', hold_id = NULL, hold_expires_at = NULL
WHERE event_id = :eventId
AND hold_id = :holdId
AND status = 'HELD'
AND hold_expires_at <= database_now;Delayed messages can be duplicated, late, or out of order. If a new holdid now owns the seat, the old predicate no longer matches. If the seat is SOLD, its status no longer matches. Releasing by seatid alone can delete another buyer's valid hold.
Additional payment authentication can approach the hold deadline. Before authorization starts, this design may atomically move a valid hold's Hold.status to PAYMENT_PENDING with one bounded grace extension. Grace occupancy still counts against active inventory and account limits. An outcome that remains unknown at the new deadline enters reconciliation; the client cannot extend indefinitely to hoard seats.
Step 6: Put unknown payment outcomes in the state machine
Checkout creates a stable paymentoperationid and reuses its idempotency key with the provider. Stripe's public API documentation explains that retrying a request with the same idempotency key returns the saved result of the first request. PaymentIntent-style objects also expose lifecycle state for payments that may need extra authentication or asynchronous completion.
Use this sequence:
- In a short transaction, validate the hold, ticket limit, price, and deadline, then create the
Orderand authorization operation. If a grace period is needed, moveHold.statustoPAYMENT_PENDINGin the same transaction. - Call payment authorization outside the transaction. A timeout becomes
UNKNOWN; it does not create a second operation. - After confirmed authorization, lock the seats again, match the
hold_id, set all seats toSOLD, set the order toCONFIRMED, and write an outbox event in one transaction. - Capture after commit. Repeated capture uses the same operation ID. Ticket issuance consumes only the committed confirmation event.
- If authorization succeeded but the hold can no longer be confirmed, void the authorization. If the sold transaction committed and capture is unknown, preserve the seats and order while webhooks, active lookup, and reconciliation converge. Do not release and sell them again. If capture is definitively failed and cannot be retried, cancel the order and release its seats in an audited compensation transaction before ticket issuance.
This ordering permits a temporary “sold, capture pending” state but never gives the same seat to two buyers. If the business can only capture immediately, a successful charge followed by a failed sold transaction needs an auditable void or refund. That provider capability increases customer and operational risk and should be stated explicitly.
Seat and order state move monotonically. A browser redirect to a success page cannot authorize ticket issuance. Only a verified provider response, webhook, or active lookup advances payment state. Deduplicate webhooks by provider event ID and accept only legal transitions.
Step 7: Design failures, scaling, and degradation
- The event shard is unavailable: Stop admission and new holds for that event and retain users in the waiting room. Browsing may say booking is temporarily unavailable; a stale replica cannot continue selling.
- Cache or delta delivery fails: Fall back to lower-frequency versioned snapshots. The database still decides the hold.
- An expiry message is lost: Conditional reclaim and the compensation scan still recover seats. Monitor expired backlog and the oldest overdue hold.
- The payment provider slows down: Reduce admission and checkout concurrency and preserve unknown operations. Do not switch providers automatically and risk a second charge.
- A response is lost after commit: The idempotency key retrieves the original hold, order, or payment operation.
- One event becomes extremely hot: Give it dedicated database resources, rate-limit by event, and reduce nonessential reads. Do not split one multi-seat transaction across shards.
- A region fails: Each event has one write home and a verified failover. Before a new primary takes over, prove that the old primary cannot still write, avoiding active-active overselling.
The waiting room also needs resistance to token copying, replay, and bypass. Tokens are signed, short-lived, and bound to event and account; the service limits concurrent sessions and ticket quantity, while high-risk traffic receives extra verification. These controls reduce a bot advantage. They do not independently prove that a buyer is human or that allocation is perfectly fair.
Step 8: Verify invariants with fault injection
In addition to feature tests, assert machine-checkable properties:
count(valid SOLD orders for one seat) <= 1
every CONFIRMED order owns exactly its recorded seats
every SOLD seat points to one CONFIRMED or payment-reconciling order
an expiry action changes only its own hold_id
replaying one idempotent request does not create new business stateSend thousands of concurrent clients after one seat and expect one valid hold. Then have clients request the same three-seat set in different orders to verify all-or-nothing behavior, fixed lock order, and deadlock retries. Move time across the expiry boundary while interleaving a new hold, expiry message, payment authorization, and confirmation. An old cleanup task must never alter the new state.
Kill a process or lose a response immediately after hold commit, before and after authorization returns, after the sold commit, after capture submission, and before and after outbox publication. Duplicate webhooks and delayed messages; disconnect the cache, payment provider, and database primary. Judge the result using event-level sold count, lock wait, conditional-conflict rate, hold expiry rate, age of unknown payments, queue wait and admission rate, and ticket-issuance reconciliation differences. HTTP 200 alone proves little.
High-Quality Sample Answer
“I would split the problem into traffic protection and inventory correctness. Two million arrivals in five minutes average about 6,667 per second, with a 50,000-request-per-second edge peak, while inventory accepts only 2,000 hold attempts per second. I would create an event-scoped virtual waiting room. It absorbs refresh traffic at the edge and issues short-lived, account-bound admission tokens according to the health of the event shard and payment path. Queue ordering is an explicit product policy; it does not provide seat exclusion.
The event page and static venue map use a CDN. The seat map uses a versioned snapshot plus deltas and may be a few seconds stale; only the hold decides. Each event has one relational database write shard, so its 50,000 seats are not split across shards. The hold API accepts one to six sorted seat IDs and an idempotency key. A short transaction locks those rows and writes one hold_id with a database-time five-minute expiry only when every seat is available or expired. Otherwise, all fail. The transaction releases its locks at commit; checkout retains durable hold state, not a database lock.
A delayed queue and scanner clean up expired holds, but every release matches hold_id, HELD, and the deadline. A late old message therefore cannot release a new hold or sold seat. The idempotency key and request digest commit with the hold, so a response lost after commit only retrieves the original result.
For payment, the prompt assumes separate authorization and capture. I create a stable payment operation and authorize first. A timeout is unknown and uses the same operation ID for lookup or retry. After authorization, a transaction revalidates the hold, price, limit, and deadline, then moves the seats to SOLD, the order to CONFIRMED, and writes the outbox. Capture and ticket issuance follow. If authorization succeeded but seat confirmation fails, I void the authorization. If the seats are confirmed and capture is unknown, I preserve the order and converge through webhooks, lookup, and reconciliation; I never release and resell.
Scaling is event-scoped: ordinary events share shards, hot events receive dedicated resources, and one event has one write home. If the database, payment provider, or issuer slows down, the waiting room reduces admission and can pause holds. For verification, thousands of clients contend for one seat and overlapping seat sets while I inject late expiry, crashes after commit, lost payment responses, duplicate webhooks, cache loss, and primary failure. The design passes only while it proves at most one valid sold order per seat, no new state from replay, and no new hold changed by an old expiry action.”
Common Mistakes
- Locking in Redis and writing inventory asynchronously → Lock and database writes can disagree, producing two authorities → Perform the condition, durable hold, and idempotent result in one authoritative database transaction.
- Holding a row lock for the five-minute checkout → Long transactions consume connections, block writers, and recover poorly after abandonment → Use locks only for transitions and represent checkout with durable hold state.
- Assuming the virtual waiting room prevents overselling → It controls admission while admitted buyers still contend on the same rows → Enforce exclusion separately in the inventory transaction.
- Releasing expiry by seat ID alone → A late message can erase a new hold or sold state → Match
hold_id, state, and deadline together. - Charging because the seat map shows available → The read model may be stale and another buyer can win first → Acquire the authoritative hold before payment.
- Releasing and retrying under a new key after payment timeout → The first operation may have succeeded, causing resale or a duplicate charge → Preserve unknown state, reuse the operation ID, and converge through webhooks, lookup, and reconciliation.
- Randomly sharding every seat of a hot event → One multi-seat hold crosses shards and loses simple atomicity → Keep an event on one write shard until measured capacity proves a more complex inventory partition is necessary.
- Promising strict global FIFO → Network latency, reconnects, device switching, and bots alter observable order → Define queue granularity, identity, reconnect, and risk policies as a verifiable fairness contract.
- Load-testing only average throughput → Opening demand concentrates on a few seats, hiding lock waits and retry storms behind a uniform distribution → Test hot seats, overlapping groups, instant bursts, and slowed dependencies.
Follow-Up Questions and Responses
Follow-up 1: What changes for general admission when only a ticket-tier quantity must not oversell?
Replace per-seat state with InventoryBucket(eventid, tierid, available, held, sold, version). A hold transaction conditionally requires available >= quantity, decrements once, and creates the hold. Expiry and confirmation still move counts idempotently by hold_id. A highly contended tier can use several buckets to spread writes, but a quota control plane must allocate bucket capacities and keep their sum within real inventory. Removing atomic seat selection simplifies storage; expiry and unknown-payment races remain.
Follow-up 2: Authorization averages 90 seconds and extra authentication can exceed five minutes. How do you stop long-lived holds?
Check remaining time before payment begins and reject a new authentication flow when too little remains. Once started, move the hold to a one-time bounded PAYMENT_PENDING grace state, with per-account and event-wide limits on that inventory. At grace expiry, stop initiating work and query the provider. Release only after confirming no authorization; complete or void a confirmed authorization. Use conversion and inventory-turnover data to tune the five-minute hold and grace rather than letting clients renew repeatedly.
Follow-up 3: The primary write region disappears during the sale. Should another region take over immediately?
Do not start a second writer based only on a timeout. Pause admission and new holds for the event. Use a consensus lease, database failover, or isolation of the old primary to prove there is one writer, then let the new primary resume from a confirmed log position. Queueing and read-only degradation can continue during the gap; unprovably unique holds cannot. Reconcile orders, seats, and payment operations before raising admission again.
Follow-up 4: How do you improve fairness and limit bots without making fraud controls a correctness dependency?
Use verified accounts, purchase limits, rate limits, device and behavior signals, and extra challenges for high-risk sessions. Queue tokens are signed, short-lived, and bound to event and account; duplicate sessions are merged or rejected by policy. The sale can use coarse arrival-time FIFO or randomly assign positions to pre-queued users. Whether risk detection misses a bot or not, the database enforces the same hold, limit, and order invariants. A risk-control miss affects allocation fairness; it must not cause overselling.
Follow-up 5: Why not protect every seat with an existing distributed lock service?
When authoritative inventory is already in a relational database with conditional writes and short transactions, a separate lock service creates a second state that must agree with inventory, plus lease-expiry and fencing boundaries. Row locks or conditional updates place exclusion and the durable write in one transaction and are simpler. Consider a distributed lock only when inventory spans stores that cannot transact together or when the critical section protects an external resource. Even then, the durable store still needs a version or fencing token to reject a late holder.