System Design course menu
System Design: Ticket Booking Like BookMyShow
Design a ticket booking system that never double-sells a seat: temporary seat holds, optimistic and pessimistic locking, payment flows, and virtual waiting rooms for big sales.
On this page
Booking systems test whether you understand concurrency: two people must never get the same seat, even if they click at the same moment, and a popular sale can bring huge spikes.
1. Requirements#
Functional: browse events, see seats, hold seats during payment, and confirm the booking.
Non-functional: no double booking, strong consistency for bookings, and survival of big traffic spikes. Browsing can be slightly stale.
2. Estimates#
fans = 2_000_000
seats = 50_000
sale_window_s = 300
print(f"Requests in the first minutes: about {fans * 3 / sale_window_s:,.0f}/s")
print(f"Fans per seat: {fans / seats:.0f}")Requests in the first minutes: about 20,000/s
Fans per seat: 40Normal traffic is low, and it can rise a thousand times when a popular sale opens.
3. Data model#
events(id, name, venue_id, starts_at)
seats(id, event_id, row, number, status, held_until, booking_id)
bookings(id, user_id, event_id, status, total, created_at)Bookings need transactions, so a relational database such as PostgreSQL is the right choice. The seat map for browsing can be cached.
4. The race condition#
Two users both see a seat as free, and both book it:
seat = {"status": "free", "owner": None}
def naive_book(user, snapshot):
if snapshot["status"] == "free":
seat.update(status="booked", owner=user)
return f"{user} booked it"
return f"{user} failed"
read_by_asha = dict(seat)
read_by_ravi = dict(seat)
print(naive_book("Asha", read_by_asha))
print(naive_book("Ravi", read_by_ravi))
print("Owner:", seat["owner"])Asha booked it
Ravi booked it
Owner: RaviBoth were told that they had the seat.
5. Preventing double booking#
Pessimistic locking locks the row, so that others wait:
BEGIN;
SELECT status FROM seats WHERE id = 42 FOR UPDATE;
UPDATE seats SET status = 'held', held_until = now() + interval '5 minutes' WHERE id = 42;
COMMIT;Optimistic locking changes a row only if it is unchanged since you read it. The WHERE clause does the check, in one atomic step:
UPDATE seats SET status = 'held'
WHERE id = 42 AND status = 'free';
-- 1 row changed: you got it. 0 rows changed: someone else did.Here is the same idea, using a version number:
seat = {"status": "free", "owner": None, "version": 1}
def book(user, version_seen):
if seat["version"] != version_seen:
return f"{user}: seat already taken"
seat.update(status="booked", owner=user, version=version_seen + 1)
return f"{user}: booked"
v_asha = seat["version"]
v_ravi = seat["version"]
print(book("Asha", v_asha))
print(book("Ravi", v_ravi))Asha: booked
Ravi: seat already takenAdd a unique constraint in any case, such as on (event_id, seat_id) for confirmed bookings. The database then protects you, even if the app has a bug.
6. Temporary holds#
Payment takes minutes, so a seat is held first. If payment does not complete, the hold expires:
FREE --> HELD (5 minutes) --> BOOKED
|
+--> hold expired --> FREEholds = {}
def hold(seat_id, user, now, ttl=300):
current = holds.get(seat_id)
if current and current["until"] > now:
return False
holds[seat_id] = {"user": user, "until": now + ttl}
return True
print("Asha holds A1 at t=0: ", hold("A1", "Asha", 0))
print("Ravi tries at t=100: ", hold("A1", "Ravi", 100))
print("Ravi tries at t=301: ", hold("A1", "Ravi", 301))Asha holds A1 at t=0: True
Ravi tries at t=100: False
Ravi tries at t=301: TrueTreating an expired hold as free needs no clean-up job for correctness, though a background job can tidy the data. Many systems keep holds in Redis, with an expiry.
7. Payment#
- Hold the seat.
- Create a booking with status
PENDING, and an idempotency key. - Send the user to the payment provider.
- The provider's webhook confirms the result.
- Mark the booking
CONFIRMEDand the seatBOOKED, in one transaction.
Webhooks can arrive twice, or late, so the handler must be idempotent. If payment succeeds after a hold has expired, refund it automatically.
8. Handling a big sale#
A sudden rush can overwhelm the database. A virtual waiting room queues users before they reach the booking system, letting in only as many as it can handle:
capacity_per_minute = 5_000
queue_position = 180_000
print(f"Estimated wait: {queue_position / capacity_per_minute:.0f} minutes")Estimated wait: 36 minutesOther protections:
- Serve event pages from a CDN and cache.
- Rate limit each user, and fight bots.
- Keep seat availability in Redis for fast reads, and treat the database as the final authority.
Trade-offs to mention#
- Seat maps can be stale; bookings cannot.
- A longer hold is kinder to users, but it locks up inventory.
- A waiting room adds delay, but it protects everyone.
Key takeaway#
Finished this lesson?
Related lessons
Recommended System Design resources
- System Design Interview by Alex Xu
Step-by-step walkthroughs of common design questions, for interview preparation.
Some links are affiliate links. If you buy through them, Laviyx Learn may earn a commission at no extra cost to you. As an Amazon Associate, we earn from qualifying purchases.