Low-Level Design: Event Booking System — Seat Selection, Inventory Lock, and Payment Coordination

Core Entities

Event: event_id, name, venue_id, event_date, doors_open, start_time, status (ON_SALE, SOLD_OUT, CANCELLED, COMPLETED), total_capacity. Venue: venue_id, name, address, seating_map_url, total_seats. Section: section_id, venue_id, event_id, name (Floor A, Section 101), capacity, base_price, available_count. Seat: seat_id, section_id, row, seat_number, status (AVAILABLE, HELD, SOLD, BLOCKED), held_by_session_id, held_until. Order: order_id, user_id, event_id, status (PENDING, CONFIRMED, CANCELLED, REFUNDED), total_amount, created_at, expires_at. OrderItem: item_id, order_id, seat_id, price, fee_amount. Ticket: ticket_id, order_item_id, barcode (unique), is_valid, scanned_at.

Seat Hold and Expiry

The core challenge: seats must be reserved while the user completes payment, but not held forever (prevents inventory lock-up). Two-phase approach: Hold (temporary): when user selects seats, lock them for N minutes (typically 10-15 minutes). Payment: user completes payment within the hold window. Confirm: on successful payment, mark seats as SOLD and create tickets.

class SeatService:
    HOLD_DURATION_MINUTES = 10

    def hold_seats(self, session_id: str, seat_ids: list[int]) -> Order:
        with self.db.transaction():
            # Lock the rows with SELECT FOR UPDATE
            seats = self.repo.lock_seats(seat_ids)
            for seat in seats:
                if seat.status != SeatStatus.AVAILABLE:
                    raise SeatUnavailableError(seat.seat_id)

            holds_expire_at = datetime.utcnow() + timedelta(minutes=self.HOLD_DURATION_MINUTES)
            for seat in seats:
                seat.status = SeatStatus.HELD
                seat.held_by_session_id = session_id
                seat.held_until = holds_expire_at

            order = Order(session_id=session_id,
                          status=OrderStatus.PENDING,
                          expires_at=holds_expire_at)
            self.repo.save_all(seats + [order])
            return order

Hold Expiry and Cleanup

A background job runs every 60 seconds: SELECT seat_id FROM seats WHERE status=’HELD’ AND held_until < NOW(). For each expired hold: UPDATE seats SET status='AVAILABLE', held_by_session_id=NULL, held_until=NULL WHERE seat_id=:id AND status='HELD'. Also cancel the associated pending order. Optimistic check (AND status='HELD'): prevents releasing a seat that was just confirmed by a concurrent payment. After releasing: increment section.available_count so the section map shows updated availability. Redis pub/sub: broadcast the seat availability change so open browser sessions can update their seat maps in real time without polling.

Payment and Ticket Issuance

class CheckoutService:
    def confirm_order(self, order_id: int, payment_token: str) -> list[Ticket]:
        with self.db.transaction():
            order = self.repo.lock_order(order_id)  # SELECT FOR UPDATE
            if order.status != OrderStatus.PENDING:
                raise OrderNotPendingError()
            if datetime.utcnow() > order.expires_at:
                self._cancel_order(order)
                raise OrderExpiredError()

            # Process payment
            charge = self.payment.charge(payment_token, order.total_amount,
                                          idempotency_key=str(order_id))
            if charge.status != "succeeded":
                raise PaymentFailedError(charge.decline_reason)

            # Confirm seats
            seats = self.repo.get_seats_for_order(order_id)
            for seat in seats:
                seat.status = SeatStatus.SOLD
            order.status = OrderStatus.CONFIRMED
            order.payment_id = charge.id

            # Generate tickets with unique barcodes
            tickets = [Ticket(seat_id=s.seat_id,
                              barcode=self._generate_barcode(),
                              is_valid=True)
                       for s in seats]
            self.repo.save_all(seats + [order] + tickets)
            return tickets

High-Concurrency Seat Selection

For popular events (Taylor Swift, playoff tickets): thousands of users simultaneously try to select the same seats. The SELECT FOR UPDATE approach works but creates lock contention. Optimizations: (1) Virtual queue: admit users to the seat selection flow in batches (like flash sale waiting room). Reduces simultaneous seat selection attempts. (2) Section-level inventory: show sections with available_count > 0 on the event map. Only allow entering seat-level selection for sections with availability. Reduces wasted attempts. (3) Auto-assign for General Admission: instead of user-selected seats, system assigns seats automatically (best available algorithm). Eliminates seat selection contention entirely. (4) Read replica for browsing: serve the event map and seat availability from a read replica. Only route to the primary when actually holding a seat.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “Why use SELECT FOR UPDATE for seat holds instead of a Redis-based lock?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “SELECT FOR UPDATE is a database-level row lock. All seat state and hold state live in the database — the lock and the state update happen atomically in one transaction. If the process crashes after acquiring the lock, the transaction rolls back automatically, releasing the lock. No orphaned locks. Redis-based distributed locks (Redlock) require: acquire lock in Redis, update database, release lock. If the process crashes between acquiring the Redis lock and updating the database: the Redis lock expires after TTL, but the database state is inconsistent (seat may or may not be held). SELECT FOR UPDATE is simpler, more reliable, and appropriate for transactional systems like ticketing. Redis-based locks are better suited for non-transactional systems or when you need cross-service coordination that spans multiple databases. For seat booking: use SELECT FOR UPDATE. The database is the single source of truth; keep the locking mechanism co-located with the state.”
}
},
{
“@type”: “Question”,
“name”: “How do you handle the case where a user’s payment times out but their seat hold has not yet expired?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “The hold has a 10-minute TTL (held_until). If payment processing times out (e.g., Stripe response takes >30 seconds), the hold is still active. Three cases: (1) Payment gateway never responded: the charge may or may not have gone through. Use idempotency keys on the payment API call — retry the same idempotency key to get the result of the original charge without double-charging. If the charge succeeded: confirm the order. If it failed: release the hold. (2) User’s browser closed mid-payment: the hold remains until expiry. Background job releases it at held_until. (3) User wants to retry: if the order is still PENDING and hold is still active (not expired), allow the user to retry payment. The hold window is still valid. Implementation: the payment retry endpoint re-runs the charge attempt against the same pending order. If the hold has already expired: return a “hold expired” error and let the user restart seat selection.”
}
},
{
“@type”: “Question”,
“name”: “How do you generate barcodes for tickets and validate them at the venue?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Barcode generation: generate a cryptographically random 16-byte value (128 bits of entropy), encode as base-32 (URL-safe, readable). Store as barcode column on the Ticket row with a UNIQUE constraint. The barcode effectively acts as a bearer token — possessing it grants entry. Validation at venue: scanner app makes an API call: POST /tickets/scan {barcode: “ABC123″}. Server looks up the ticket by barcode, checks is_valid = TRUE and scanned_at IS NULL. If valid: UPDATE tickets SET scanned_at = NOW(), scanned_by = :scanner_id WHERE barcode = :code AND is_valid = TRUE AND scanned_at IS NULL. If rows_affected = 0: already scanned or invalid — reject entry. The conditional update is atomic: prevents two scanners simultaneously accepting the same ticket. Offline mode for scanners: download all valid barcodes for the event to the scanner device (encrypted). Validate locally when connectivity is poor. Sync scanned status back when connectivity returns. Ticket transfer: if tickets can be transferred, invalidate the original barcode and issue a new one to the recipient.”
}
},
{
“@type”: “Question”,
“name”: “How do you implement the “best available seats” algorithm for auto-assignment?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Best available: given a request for N seats, find N adjacent seats that maximize desirability (closeness to center, row quality). Algorithm: (1) Define a seat score: lower score = better seat. score = abs(seat.col – center_col) + row_distance_from_stage. (2) Group available seats by section, then by row. (3) For each row, find runs of N consecutive available seats. Score each run by the average seat score. (4) Return the top-scoring run across all rows and sections. Implementation: maintain an in-memory seat availability matrix per event (loaded at event load time, updated on holds/sales). Query the matrix for available runs — O(capacity) per query, fast for venues up to 50,000 seats. Adjacent seat constraint: most venues have a “no orphan seats” rule — avoid leaving a single isolated available seat in a row. When assigning N seats, ensure the remaining seats in the row are either 0 or >= 2. Handle this by preferring runs that leave 0 or >=2 remaining seats at each side.”
}
},
{
“@type”: “Question”,
“name”: “How would you handle a complete sellout event with 50,000 concurrent users?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “50,000 concurrent users competing for limited seats. Key patterns: (1) Virtual queue: instead of letting all 50K users hit seat selection simultaneously, place them in a randomized queue. Admit batches (e.g., 500 every 30 seconds). Each admitted user gets a session token valid for 15 minutes to complete purchase. (2) Read replicas for browsing: event detail and seat map reads go to PostgreSQL read replicas. Only seat hold writes go to the primary. Reduces primary load by 80-90%. (3) Section availability counter: maintain available_count per section in Redis. Fast O(1) check before attempting the database hold. If available_count = 0: reject immediately without hitting the database. (4) Connection pooling: use PgBouncer in front of PostgreSQL. 50K concurrent users do not need 50K database connections — a pool of 200 is sufficient. (5) Idempotent hold: if the user’s browser retries the hold request (due to network error), use the session_id as an idempotency key — return the existing hold rather than creating a new one.”
}
}
]
}

Asked at: Stripe Interview Guide

Asked at: Shopify Interview Guide

Asked at: Airbnb Interview Guide

Asked at: Uber Interview Guide

Scroll to Top