Skip to content
Laviyx Learn

Try

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.

Advanced7 min readUpdated
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#

Python
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}")
Output
Requests in the first minutes: about 20,000/s
Fans per seat: 40

Normal traffic is low, and it can rise a thousand times when a popular sale opens.

3. Data model#

Text
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:

Python
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"])
Output
Asha booked it
Ravi booked it
Owner: Ravi

Both were told that they had the seat.

5. Preventing double booking#

Pessimistic locking locks the row, so that others wait:

SQL
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:

SQL
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:

Python
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))
Output
Asha: booked
Ravi: seat already taken
MethodGood forWeakness
PessimisticHeavy contentionWaiting, and possible deadlocks
OptimisticFew conflictsMany failed attempts in a rush
Unique constraintA simple safety netOnly rejects at the end

Add 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:

Text
FREE --> HELD (5 minutes) --> BOOKED
           |
           +--> hold expired --> FREE
Python
holds = {}


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))
Output
Asha holds A1 at t=0:   True
Ravi tries at t=100:    False
Ravi tries at t=301:    True

Treating 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#

  1. Hold the seat.
  2. Create a booking with status PENDING, and an idempotency key.
  3. Send the user to the payment provider.
  4. The provider's webhook confirms the result.
  5. Mark the booking CONFIRMED and the seat BOOKED, 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:

Python
capacity_per_minute = 5_000
queue_position = 180_000
print(f"Estimated wait: {queue_position / capacity_per_minute:.0f} minutes")
Output
Estimated wait: 36 minutes

Other 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#

Next: System design interview questions.

Finished this lesson?

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.