A ticket site can show a seat map that is a few seconds out of date. It cannot let two fans hold or buy the same seat. This design keeps one authority for seat inventory and treats payment as a separate process whose outcome can stay unknown for a while.
This is one strong approach. Scale figures come from the question's brief; the timeouts and policies added below are assumptions.
State the guarantees before choosing components
Fans browse an event, select up to eight exact seats, hold them for ten minutes, and pay through an outside processor. Successful payment produces tickets. Unpaid holds return to sale. Resale, event search and gate scanning are outside scope.
Around 20,000 events are on sale at once. Normal sales are about 1,000 tickets a minute, but a major release brings one to two million fans within minutes for about 50,000 seats. These are different problems. The normal rate says nothing about thousands of fans trying for the same front row.
The guarantees that matter are one active owner per seat, no duplicate charge for one checkout, and durable tickets after a confirmed payment. Browsing may lag inventory by a few seconds. The processor usually answers in one to three seconds, but some payments take minutes and a few calls time out. A timeout means the outcome is unknown, not that the charge failed.
Model inventory and checkout separately
Use these entities:
- Event: venue, sale start and seat-map reference.
- Event seat: event and seat IDs, price, inventory state, hold ID and deadline.
- Hold: customer, selected seats, expiry and lifecycle state.
- Order: one per hold, amount, processor payment ID and payment state.
- Ticket: a unique entitlement for one sold event seat, belonging to an order.
The inventory lifecycle is available -> held -> paying -> sold. A held seat can become available after expiry. A paying seat returns to available only after the processor confirms a final unsuccessful outcome or a cancellation.
The conceptual API includes GET /events/{id}/seats, POST /events/{id}/holds, POST /holds/{id}/checkout, and GET /orders/{id}. Hold creation accepts seat IDs and returns a hold ID plus expiry. Checkout accepts an idempotency key and returns an order whose status the client can check again later. The customer identity comes from the session, and prices come from the inventory records, not the request.
Put a waiting room before the contested write
Serve static event descriptions and venue geometry from a CDN. Seat states come from a short-lived availability projection, so millions of viewers do not each query the transactional database.
For a major on-sale, the waiting room admits a bounded number of shoppers and adjusts the admission rate using hold latency, error rate and database capacity. Pick a fairness policy you can explain to fans: randomise the order of everyone who arrived before the sale started, then queue later arrivals by time. Say in the interview that this is a product decision.
The booking service checks a signed, expiring admission token tied to the event, so a direct API call cannot skip the queue. Also cap active holds and requests per account. Admission control limits pressure on scarce inventory. Stopping bots and enforcing identity rules takes more than a signed token.
Reserve all requested seats in one transaction
The seat map may still show B12 as free after someone has held it, so the hold path checks the database row and ignores the cache.
Validate that the seat IDs are distinct and within the eight-seat maximum. For one hold, begin a short transaction, lock the requested event-seat rows in seat-ID order, and check that each is available or has an expired hold. Read database time after acquiring the locks. If any seat is sold, paying, or held by someone else before its deadline, roll back the whole request and return 409.
Otherwise assign every selected seat the new hold ID and a ten-minute deadline, insert the hold, and commit. Two seats are held together or not at all. Locking in a fixed order reduces deadlocks, but the service must still retry transactions that fail.
Two fans cannot both commit ownership of B12, because their transactions queue on that row and the second one rechecks its state. PostgreSQL documents how row updates and locking interact with concurrent transactions. A conditional multi-row update that checks the number of rows changed is another valid implementation.
As the system grows, partition inventory by event so one event's hold transaction stays on one shard. More shards add capacity for other events but do nothing for contention on the same seat, so the waiting room is still needed.
Expire holds without relying on the cleanup worker
A background worker cleans up expired holds. Its update requires the same hold ID, state held, and a passed deadline. A delayed cleanup message for an old hold therefore cannot release a seat that has since been held again or sold.
The hold path already treats an expired held row as available, and so does the seat-map projection. If the worker crashes, cleanup is delayed but expired seats can still be held.
Make decisions with server deadlines and database time. The browser countdown is only a display. A fan can arrive with a few seconds left and still lose the seat if processing starts after expiry, so show the confirmed result rather than treating the countdown as a guarantee.
Move to paying before contacting the processor
Checkout starts with a transaction that checks the hold belongs to this customer and has not expired, creates or returns its unique order, and moves its seats from held to paying. Commit before calling the external processor. Do not keep inventory row locks open across a bank request.
Use a stable idempotency key derived from the order for processor calls. The service also stores the customer's request key and rejects reuse with different parameters. Stripe's idempotency documentation describes this retry mechanism and how long keys are kept. The internal order record stays the authority after the provider's key-retention window ends.
Create the processor's payment object idempotently and store its ID before confirming the charge. If creation times out, retry with the same key inside the retention window to recover that ID. Never start a charge whose payment ID the reconciler cannot find again.
If the processor confirms success, one database transaction conditionally changes the order and its seats to sold and inserts unique tickets. Repeating finalization returns the original result. Put notification work in a transactional outbox so a crash after the sale does not lose the email job.
A verified webhook and a successful synchronous response both call the same finalizer. Stripe documents duplicate deliveries and unordered events. Deduplicating by event ID helps, and checking the order's current state also stops two different events from creating duplicate tickets. A browser redirect is never proof of payment.
Reconcile uncertain payments without reselling their seats
Suppose a charge succeeds but the API response is lost. The order stays in paying. A reconciler queries the processor with the stored payment ID and completes the sale once success is confirmed. The browser polls order status instead of submitting a new charge.
Assume a fifteen-minute investigation deadline before requesting cancellation. Reaching the deadline does not free the seats on its own. Release them only after the provider confirms cancellation or failure. Some payments may stay unresolved longer and keep their seats off sale. That lost inventory is what it costs to never resell a seat that may already be paid for.
No ordinary transaction spans your database and a card network. If a late success arrives after the seats were correctly released, the finalizer must not take them back from their new owner. It schedules an idempotent refund and tells the fan. Keeping the promise that nobody pays without getting tickets takes this recovery path and a refund status the fan can see, because one atomic commit across both systems is not available.
Keep browsing useful under contention
Update the availability projection after holds and sales commit, with a short TTL as a backstop. Coalesce refreshes so a cache miss does not trigger thousands of rebuilds. Return fresh availability with a failed hold, so the fan can choose other seats instead of repeating the same request.
The hold path never reads the map to decide ownership. Pushing changes to clients can make the map fresher, but adds connection and fan-out cost during the largest event. A short cache lifetime and admission control may be enough to start.
Another architecture gives each event a single command processor that orders holds. It simplifies contention, but needs durable commands, fencing during failover and partitioning across events. On the payment side, you could authorise first and capture only after securing seats. That keeps fewer seats in paying, at the cost of more voided authorisations. Whichever you choose, say what it costs.
What happens after a payment timeout?
Practise the ticket booking question in Grind. Start by explaining what the fan sees after a successful charge whose response is lost. Then talk through two fans selecting the same seats and a cleanup worker arriving late. For each case, point to the conditional state change that keeps it safe. Explain why your waiting-room policy is fair.


