The check-then-act race in the obvious code
Most first drafts look like this, often with a ConcurrentHashMap that makes the class feel thread-safe.
public boolean book(String showId, String seatId, String userId) {
Show show = shows.get(showId);
Seat seat = show.seat(seatId);
if (seat.isAvailable()) {
seat.bookFor(userId);
return true;
}
return false;
}
Two threads call book for seat F12. Both read isAvailable() as true, both call bookFor, and both return true. One user's booking is silently overwritten, and you have taken two payments for one seat. That is the whole race condition.
Nothing on the data path is unsafe in isolation. The map lookup is atomic, and a volatile status field would make every read current. The defect is that a check-then-act decision spans two operations, and no per-operation guarantee closes the window between them. That is why "make the field volatile" or "use a concurrent map" is the answer an interviewer is waiting to mark down.
Choosing the scope of the lock
There are three plausible places for the lock, and the right one follows from which invariant you are protecting.
A synchronized on book itself, or one lock for the whole service, is correct and is where you should start if you are short of time. Its cost is that a booking for a Tuesday matinee waits behind one for a Saturday premiere, though they share no state. It is also only correct if every other mutating method, including cancel, takes the same monitor.
A lock per seat is the finest grain. For single-seat bookings a compare-and-set on the seat's state works without any lock at all. It stops working the moment a booking spans several seats, because "all four or none" is an invariant over four seats, and four independent locks give you four atomic steps plus a window between them.
A lock per show is usually the right answer. Seats in one show share invariants once group bookings exist, and seats in different shows share nothing. Every show's state is guarded by that show's private lock, so all-or-nothing group booking is a loop inside one critical section.
The lock is a private final object rather than this. That keeps the lock graph yours: external code holding a Show reference cannot take your monitor and stall your bookings. The object must also be stable. If your design replaces Show instances with updated copies, locking on the instance you happened to fetch protects nothing.
Holding the lock versus holding the seat
The tempting next step is to call the payment provider inside the critical section, so the seat cannot be taken while the charge runs. That turns a lock held for microseconds into one held for seconds. It also blocks every other buyer for that show behind one user's bank, and it makes your correctness depend on a remote timeout.
The fix is to separate the two things being held. The lock is held only for the in-memory transition, and the seat is held by state. hold moves the seat to HELD with an expiry and releases the lock. The caller then charges the card with no lock held, and calls confirm. That method re-checks, under the lock, that the hold still belongs to this user and has not expired. If confirm returns false, the payment must be refunded or voided, and that compensating path belongs in the design. Moving the hold into a payment-pending state before the charge, so expiry cannot release it mid-payment, is the refinement worked through in the movie-ticket booking design.
Expiry here is lazy: an expired hold is treated as absent the next time anyone looks, so correctness never depends on a sweeper thread running on time. Injecting a Clock is what lets a test move time past the expiry without sleeping.
Why locking the read is not enough
The common half-fix makes isAvailable and bookFor each synchronized. Each method is now atomic, and the check-then-act sequence is still not. Another thread can run between the two calls, exactly as before, so the race condition survives every keyword you add to the getters.
The question tests whether you can name the unit of atomicity. That unit is the business decision, "if free then mine", and every path that changes seat state has to go through it under the same lock. A cancel method that releases a seat without taking the show's lock reopens the race from the other side. The same goes for an admin "release all holds" job. The wider form of this mistake is covered in losing an update on a ConcurrentHashMap.
What this lock cannot do
Everything above lives in one JVM. Run two instances of the service behind a load balancer and each has its own Show object and its own lock. The check-then-act race returns, now between processes. At that point the guard has to move to a shared store, such as a conditional write or a unique constraint, and holds and payment become a distributed problem. That version is two people booking the last seat, and at stadium scale selling sixty thousand seats exactly once.
Saying this unprompted earns credit in a machine-coding round. It shows you know which guarantee your code gives and where it stops. If the interviewer then asks for a multi-show group booking, you need several show locks at once, and splitting a global lock without deadlocking picks up from there.
The lock protects a decision, so it must span the check and the write, every mutating path must take it, and it must be released before anything slow. A timed hold, not a held lock, is what keeps the seat while the user pays.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).