Theater/Screen/Show/Seat/Booking classes, temporary seat holds with expiry vs immediate commit, and preventing the double-booking race condition.
Published September 23, 2026
class Theater { List<Screen> screens; }
class Screen { String id; List<Seat> seats; }
class Seat { String id; SeatType type; }
class Show { Screen screen; Movie movie; Instant startTime; Map<String, SeatStatus> seatStatuses; } // per-show, not per-seat globally
class Booking { Show show; List<Seat> seats; User user; BookingStatus status; Instant expiresAt; }
Seat availability is tracked per Show, not as a permanent property of Seat — the same physical seat is available for a 2pm show and separately bookable for a 6pm show on the same screen. This is the detail that trips people up first: Seat itself has no booking state at all; Show.seatStatuses does.
enum SeatStatus { AVAILABLE, HELD, BOOKED }
class BookingService {
Optional<Booking> holdSeats(Show show, List<String> seatIds, User user) {
synchronized (show) { // see below for why per-show locking, not a global lock
for (String id : seatIds) {
if (show.getSeatStatus(id) != SeatStatus.AVAILABLE) return Optional.empty();
}
seatIds.forEach(id -> show.setSeatStatus(id, SeatStatus.HELD));
Booking booking = new Booking(show, seatIds, user, BookingStatus.PENDING, Instant.now().plus(Duration.ofMinutes(10)));
scheduleExpiry(booking); // if not confirmed within 10 minutes, seats auto-release back to AVAILABLE
return Optional.of(booking);
}
}
boolean confirmBooking(Booking booking) {
if (Instant.now().isAfter(booking.getExpiresAt())) return false; // hold already expired
booking.getSeats().forEach(seat -> booking.getShow().setSeatStatus(seat, SeatStatus.BOOKED));
booking.setStatus(BookingStatus.CONFIRMED);
return true;
}
}
Going straight to BOOKED on seat selection (immediate commit) would let a user select seats, abandon the checkout flow (browser closed, payment never completed), and permanently lock those seats away from every other user. A temporary HELD state with expiry — the seat is reserved just long enough to complete payment, then automatically released if the flow doesn't finish — is the standard fix, directly analogous to Parking Lot's spot-claiming problem but with a time-bounded hold instead of a permanent claim.
Two users selecting the same seat for the same show simultaneously is the exact same check-then-act race covered throughout this course (HashMap Concurrency Variants, Parking Lot — Implementation). The synchronized (show) block above locks per show, not globally across the whole theater chain — two users booking seats for different shows never contend with each other, only users targeting the same show's seat map do. At real scale, this per-show lock would typically be replaced by a database-level constraint (a unique index on (show_id, seat_id, status=BOOKED), or the optimistic-locking @Version pattern from Locking Strategies) rather than an in-process lock, since booking requests for a popular show likely arrive across multiple application server instances, not one process holding one JVM monitor.
An in-process lock and timer work for one server. With several application instances behind a load balancer, the hold has to live in shared storage. Two common implementations:
CREATE TABLE show_seat (
show_id BIGINT NOT NULL,
seat_id VARCHAR(8) NOT NULL,
status VARCHAR(10) NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE | HELD | BOOKED
hold_id UUID,
held_until TIMESTAMPTZ,
booking_id BIGINT,
PRIMARY KEY (show_id, seat_id)
);
Holding seats is a single conditional update. Expired holds count as available, so no background job is needed for correctness:
UPDATE show_seat
SET status = 'HELD', hold_id = :holdId, held_until = now() + interval '10 minutes'
WHERE show_id = :showId
AND seat_id = ANY(:seatIds)
AND (status = 'AVAILABLE' OR (status = 'HELD' AND held_until < now()));
@Transactional
public Optional<UUID> holdSeats(long showId, List<String> seatIds) {
UUID holdId = UUID.randomUUID();
int updated = seatDao.holdIfFree(showId, seatIds, holdId);
if (updated != seatIds.size()) { // someone got at least one seat first
throw new SeatsUnavailableException(seatIds); // exception → rollback releases the partial hold
}
return Optional.of(holdId);
}
WHERE condition are evaluated atomically, so two transactions can't both win the same seat.UPDATE … SET status='BOOKED' WHERE hold_id = :holdId AND held_until > now(). If the result is 0 rows, the hold expired and the payment must be refunded or the booking retried.For very hot shows (a blockbuster's opening night), holds can live in Redis: SET hold:{show}:{seat} {holdId} NX PX 600000 for each seat, reserved with a Lua script so that multiple seats are taken atomically, or not at all. The TTL expires abandoned holds automatically. Confirmed bookings are still written to the database as the durable record, and the confirmation re-checks the Redis hold before writing. This relieves the database of high-churn hold traffic, at the cost of an extra system that must be kept consistent.
The flow that ties it together:
holdId with an expiry time. The UI shows a countdown.The seat map is read far more often than it's written: thousands of people look, and few book. Serve it from a short-lived cache (a few seconds), or push updates over WebSockets for popular shows. A stale map only means a user occasionally clicks a seat that was just taken, and the hold request, being authoritative, returns a clear "seat no longer available".
Q: How would you scale seat-hold expiry across multiple application instances, given scheduleExpiry() here implies a single-process timer? A: A single in-process scheduled task doesn't survive that instance restarting or scale across multiple instances — production systems typically use a durable, distributed mechanism instead (a delayed job in a queue, or a database row with an expires_at column that a periodic sweep job checks), rather than relying on an in-memory timer tied to one process's lifetime.
Q: What happens if payment succeeds but confirmBooking() is called after the hold already expired? A: This is exactly the failure scenario worth designing for explicitly: the seats may have already been released and rebooked by someone else — the system needs to detect this (confirmBooking returning false as shown) and trigger a refund/retry flow, rather than silently confirming a booking for seats that are no longer actually held.
Q: Why lock per-show rather than per-seat, given seats are the actual contended resource? A: Per-seat locking would technically be finer-grained and allow slightly more parallelism, but checking availability for MULTIPLE seats atomically (a user typically selects several seats in one booking) requires holding all their locks together anyway to avoid a partial-success race — per-show locking is simpler to reason about correctly for this multi-seat-atomicity requirement, at the cost of some contention between users booking different seats on the same popular show.
Q: How do you hold several seats atomically so a group isn't split? A: Hold them in one conditional update (or one transaction) and check that the number of rows changed equals the number requested. If not, roll back. With Redis, use a Lua script that checks all seats, then sets them all, as one atomic operation.
Q: Why treat expired holds as available in the query instead of relying on a cleanup job? A: Then correctness doesn't depend on a background job's timing or health. A seat whose hold time has passed is immediately bookable. The cleanup job only tidies up data.
Q: What if the payment succeeds but the hold has already expired? A: The confirm step finds no valid hold and fails. The system should then attempt to re-hold the same seats if they're still free, and otherwise refund automatically and notify the user. Using idempotency keys ensures that retries never double-charge or double-refund.