On this page
Description
A movie booking system is the classic "two people, one seat" problem: seats are scarce, many users browse the same show at once, payment takes time, and the design must never sell a seat twice or strand one that nobody can buy. This skill models availability per show, uses timed holds between choosing seats and paying, and makes booking all-or-nothing under concurrency.
A first ticket-booking design usually hangs availability on the seat and books in one step:
Java
class Seat {
boolean booked;
}
boolean book(List<Seat> seats) {
for (Seat s : seats) {
if (s.booked) return false;
s.booked = true; // marks as it goes
}
return true;
}
Python
class Seat:
def __init__(self):
self.booked = False
def book(seats: list[Seat]) -> bool:
for s in seats:
if s.booked:
return False
s.booked = True # marks as it goes
return True
Every part of that fails under real use. The same physical seat is sold for every show - its booked flag can't belong to the seat. A group of four fails on the third seat after marking the first two, and those two stay taken. Nobody has paid yet, so either seats are "booked" by people who then abandon checkout, or two people pay for the same seat. And two users clicking at the same instant both read booked == false.
We'll go from simple to more technical:
- The model: shows, seats per show, and timed holds.
- Holding and confirming seats, with lazy expiry.
- Concurrency: one lock per show, or ordered per-seat locks.
- The bugs that double-sell or strand seats, and the tradeoffs in hold length.
The approach
- Clarify. Shows, screens and layouts; seat types and prices; how long a seat is held during payment; whether a multi-seat selection may be partially filled (almost never); cancellations; one server or many?
- Model.
MovieandShow(a movie on a screen at a time) - availability belongs to the show.Seatis a value (row,number). AHold(seats, owner,expiresAt) and aBooking(seats, status). ABookingServiceowns them and exposesSeatStatus(AVAILABLE,HELD,BOOKED). - API.
holdSeats(show, user, seats)returns a hold id or throwsSeatUnavailableException;confirm(holdId)returns a booking id;releaseHold,cancelBooking,statusOf,availableSeats. - Core. Per show, seat → hold and seat → booking maps. Holds expire by comparing an injected
ClockwithexpiresAt. Every operation on a show runs under that show's lock, and a hold checks every seat before taking any.
The pieces in code
Java
public synchronized String holdSeats(String showId, String userId, List<Seat> seats) {
Show show = show(showId); // unknown show -> IllegalArgumentException
for (Seat seat : seats) requireSeat(show, seat);
for (Seat seat : seats) // check every seat first...
if (statusIn(show, seat) != SeatStatus.AVAILABLE) throw new SeatUnavailableException(seat + " is taken");
Hold hold = new Hold(newId(), showId, List.copyOf(seats), clock.instant().plus(holdTime));
for (Seat seat : seats) show.heldBy.put(seat, hold.id); // ...then take them all
holds.put(hold.id, hold);
return hold.id;
}
private SeatStatus statusIn(Show show, Seat seat) {
if (show.bookedBy.containsKey(seat)) return SeatStatus.BOOKED;
Hold hold = holds.get(show.heldBy.get(seat));
boolean live = hold != null && !hold.finished && clock.instant().isBefore(hold.expiresAt);
return live ? SeatStatus.HELD : SeatStatus.AVAILABLE; // an expired hold simply reads as free
}
Python
def hold_seats(self, show_id: str, user_id: str, seats: list[Seat]) -> str:
with self._lock:
show = self._show(show_id) # unknown show -> ValueError
for seat in seats:
self._require_seat(show, seat)
for seat in seats: # check every seat first...
if self._status_in(show, seat) is not SeatStatus.AVAILABLE:
raise SeatUnavailableError(f"{seat} is taken")
hold = Hold(self._new_id(), show_id, tuple(seats), self._now() + self._hold_time)
for seat in seats:
show.held_by[seat] = hold.id # ...then take them all
self._holds[hold.id] = hold
return hold.id
def _status_in(self, show: Show, seat: Seat) -> SeatStatus:
if seat in show.booked_by:
return SeatStatus.BOOKED
hold = self._holds.get(show.held_by.get(seat))
live = hold is not None and not hold.finished and self._now() < hold.expires_at
return SeatStatus.HELD if live else SeatStatus.AVAILABLE # an expired hold simply reads as free
- All or nothing. The check loop runs to completion before the first
put, so a failed hold takes no seats. - Lazy expiry. Nothing has to delete expired holds for the answer to be right: a hold past
expiresAtreads as available, and the nextholdSeatsoverwrites it. A periodic sweep is only housekeeping. - The clock is injected. Tests move a fake clock forward five minutes instead of waiting five minutes; the boundary ("held while
nowis beforeexpiresAt") is written once. confirmrefuses an expired or already-used hold, then moves the seats from held to booked.releaseHoldremoves only the seats still mapped to this hold -heldBy.remove(seat, holdId); in Python,if held_by.get(seat) == hold_id: del held_by[seat]- because an expired hold's seats may already belong to someone else.
Concurrency
"Is it free? Then take it" is a check-then-act: two threads can both see the seat free before either takes it. Within one process the simplest correct answer is a lock per show - here synchronized on the service, or a ReentrantLock per show when shows should proceed in parallel. (In Python, a threading.Lock held with with self._lock:, one per show for parallel shows.) Holding is a few map operations, so contention is short.
Per-seat locks allow more parallelism but invite deadlock: if one request locks A then B while another locks B then A, each waits forever. Sorting the seats and always locking in that order removes the cycle.
Once there are several application servers - or several worker processes of one Python app - an in-process lock protects nothing. The source of truth moves to the database: a unique constraint on (show_id, seat) for bookings, or a conditional update - UPDATE show_seat SET status = 'HELD', hold_id = ? WHERE show_id = ? AND seat = ? AND status = 'AVAILABLE' - and checking that every row was updated, inside one transaction.
Where you'll see it
Real ticketing systems keep holds in a fast store with a time-to-live (for example a Redis key per seat, set only if absent, with an expiry), confirm bookings in a relational database under constraints, and treat the payment gateway's callback as idempotent - it can arrive twice, and the second must not create a second booking. For very high-demand releases they put a virtual waiting room in front, admitting users in batches so the seat map isn't hammered by everyone at once.
Which tool for which job?
| You need | Use |
|---|---|
| Availability | per show, not on the seat |
| Time to pay without losing the seat | a hold with an expiry time |
| Expired holds | lazy expiry: treat a hold as gone once its time has passed |
| Many users on one show | one lock per show, or per-seat locks taken in a fixed order |
| Many servers | the database (unique constraint, conditional update) or a shared store with TTLs |
| Time in tests | an injected clock |
Common mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
| Checking availability, then booking, without a lock | two users book the same seat | check and take atomically |
| Taking seats one by one as they're checked | a partial hold stays when a later seat fails | check all, then take all |
| A release that ignores who holds the seat | a stale release frees someone else's new hold | release only your own hold |
| Holds without expiry | seats stuck forever when users leave | timed holds, expired lazily |
| Per-seat locks in request order | two requests deadlock | lock seats in a fixed (sorted) order |
Check yourself
Try to answer before opening each one - recalling beats re-reading.
1. A family of four selects seats; the third one was just held by someone else. What must the system do, and what bug makes it do otherwise?
Show answer
Reject the whole request and take none of the four seats. The bug is checking and taking in the same loop: the first two seats are held before the third fails, and they stay held by a hold nobody knows about. Check every seat first, then take them all - under the show's lock.
2. Ana's hold on seat D4 expired, Ben then held D4, and finally Ana's abandoned browser tab calls releaseHold. What should happen to D4?
Show answer
Nothing - it's Ben's now. A release must only free seats still mapped to the releasing hold (heldBy.remove(seat, holdId)); removing the seat unconditionally would put Ben's seat back on sale while he's paying for it.
3. Why doesn't synchronized protect seats once the service runs on three servers, and what does?
Show answer
Each server has its own JVM and its own lock, so two servers can each hold the same seat at the same time. The shared database has to enforce it: a unique constraint on (show_id, seat), or a conditional update that only changes rows still AVAILABLE, checked inside one transaction (or an atomic set-if-absent with expiry in a shared cache).
Key takeaways
- Availability belongs to the show, not the seat;
Seatis a value. - Book in two steps: an expiring hold while the user pays, then a confirmation. Expiry is checked against an injected clock when reading.
- A hold is all or nothing: check every seat, then take them all, under one lock per show.
- Release and confirm only what a hold still owns, and refuse expired or used holds.
- Per-seat locks need a fixed order; several servers need the database (constraints or conditional updates) to enforce one seat, one buyer.