Low-level design · Problem P12 of 15 · Bigger designs
Movie ticket booking
Booking questions are really concurrency questions. The classes are simple; the interviewer wants to hear how two people clicking the same seat at the same moment can never both get it, and what happens to seats when someone abandons payment.
1. The prompt
The prompt"Design a system like BookMyShow or Fandango. Users choose seats for a show, pay, and get tickets. Two users must never book the same seat."
2. Clarify the requirements
Spend the first five minutes here. Ask, then write the answers down where the interviewer can see them.
| Ask | Assume (unless told otherwise) |
|---|---|
| Partial holds? | No: all requested seats or none. |
| How long is a hold? | 300 seconds. |
| What if payment succeeds after the hold expired? | Confirm fails; refund the payment (or retry the hold if the seats are still free). |
| Scale? | Many shows at once; contention is per show. |
Must do
- Hold several seats for a user while they pay
- Confirm after payment; expire holds after a few minutes
- Show a seat map
Must be
- No seat sold twice, ever
- Abandoned holds free their seats automatically
- Different shows don't block each other
Out of scope
- Payment itself (an external service)
- Pricing tiers
- Search for movies and cinemas
3. The classes
| Class | Its one job |
|---|---|
| BookingService | hold() and confirm(); the rules |
| Show | Its seats, which hold owns each seat, and a lock |
| Hold | Who, which seats, until when, and whether it's confirmed |
4. How it works
5. Patterns and why
| Pattern | Where | Why it's worth it here |
|---|---|---|
| Lock per show | Show.lock / synchronized (show) | Two shows never wait for each other; one show's seats are always consistent |
| Lazy expiry | An expired hold counts as free when checked | No background job is needed for correctness |
| All-or-nothing | Check every seat, then mark every seat, inside one lock | No half-held seat groups |
Memory hook: "Hold under the lock, expire by the clock"Check and mark all seats inside the show's lock; a hold that's past its time simply stops counting.
6. The code
The last line is the race: 8 threads (or 8 async calls in TypeScript) try to hold seat A4 at the same moment, and exactly one succeeds.
from __future__ import annotations
import itertools
import threading
from dataclasses import dataclass
from typing import Optional
HOLD_SECONDS = 300
@dataclass
class Hold:
id: str
user: str
seats: tuple[str, ...]
expires_at: int
confirmed: bool = False
class Show:
def __init__(self, show_id: str, seats: list[str]) -> None:
self.id = show_id
self.seats = seats
self.owner: dict[str, Hold] = {} # seat -> the hold or booking that has it
self.lock = threading.Lock() # one lock per show: different shows never block each other
def taken(self, seat: str, now: int) -> bool:
h = self.owner.get(seat)
return h is not None and (h.confirmed or h.expires_at > now) # an expired hold counts as free
class BookingService:
def __init__(self) -> None:
self.shows: dict[str, Show] = {}
self.holds: dict[str, tuple[Show, Hold]] = {}
self.ids = itertools.count(1)
def add_show(self, show: Show) -> None:
self.shows[show.id] = show
def hold(self, show_id: str, seats: list[str], user: str, now: int) -> Optional[str]:
"""All or nothing: either every seat is held for this user, or none is."""
show = self.shows[show_id]
with show.lock:
if any(s not in show.seats or show.taken(s, now) for s in seats):
return None
h = Hold(f"H{next(self.ids)}", user, tuple(seats), now + HOLD_SECONDS)
for s in seats:
show.owner[s] = h
self.holds[h.id] = (show, h)
return h.id
def confirm(self, hold_id: str, now: int) -> bool:
"""Called after payment succeeds. Fails if the hold expired first."""
show, h = self.holds[hold_id]
with show.lock:
if h.confirmed:
return True
if h.expires_at <= now or any(show.owner.get(s) is not h for s in h.seats):
return False
h.confirmed = True
return True
def seat_map(self, show_id: str, now: int) -> str:
show = self.shows[show_id]
def mark(s: str) -> str:
h = show.owner.get(s)
if h is None or not show.taken(s, now):
return f"{s}:free"
return f"{s}:{'booked' if h.confirmed else 'held'}({h.user})"
return " ".join(mark(s) for s in show.seats)
if __name__ == "__main__":
svc = BookingService()
svc.add_show(Show("S1", ["A1", "A2", "A3", "A4"]))
h1 = svc.hold("S1", ["A1", "A2"], "ana", 0)
print("ana holds A1 A2:", h1)
print("ben holds A2 A3:", svc.hold("S1", ["A2", "A3"], "ben", 10))
h2 = svc.hold("S1", ["A3", "A4"], "ben", 10)
print("ben holds A3 A4:", h2)
print("ana confirms at 100 s:", svc.confirm(h1, 100))
print("ben confirms at 400 s:", svc.confirm(h2, 400))
print("cara holds A3 at 401 s:", svc.hold("S1", ["A3"], "cara", 401))
print(svc.seat_map("S1", 401))
wins: list[str] = []
def grab(user: str) -> None:
if svc.hold("S1", ["A4"], user, 500):
wins.append(user)
threads = [threading.Thread(target=grab, args=(f"u{i}",)) for i in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
print("8 buyers race for A4 at 500 s, holds granted:", len(wins))
const HOLD_SECONDS = 300;
interface Hold { id: string; user: string; seats: string[]; expiresAt: number; confirmed: boolean; }
class Show {
readonly owner = new Map<string, Hold>(); // seat -> the hold or booking that has it
constructor(readonly id: string, readonly seats: string[]) {}
taken(seat: string, now: number): boolean {
const h = this.owner.get(seat);
return h !== undefined && (h.confirmed || h.expiresAt > now); // an expired hold counts as free
}
}
// JavaScript runs one callback at a time, so the check-then-set in hold() can't be interleaved within one
// process. With several servers, the same all-or-nothing rule must move into the database (a transaction).
class BookingService {
private shows = new Map<string, Show>();
private holds = new Map<string, { show: Show; hold: Hold }>();
private nextId = 1;
addShow(show: Show): void { this.shows.set(show.id, show); }
/** All or nothing: either every seat is held for this user, or none is. */
hold(showId: string, seats: string[], user: string, now: number): string | null {
const show = this.shows.get(showId)!;
if (seats.some((s) => !show.seats.includes(s) || show.taken(s, now))) return null;
const h: Hold = { id: `H${this.nextId++}`, user, seats: [...seats], expiresAt: now + HOLD_SECONDS, confirmed: false };
for (const s of seats) show.owner.set(s, h);
this.holds.set(h.id, { show, hold: h });
return h.id;
}
/** Called after payment succeeds. Fails if the hold expired first. */
confirm(holdId: string, now: number): boolean {
const { show, hold: h } = this.holds.get(holdId)!;
if (h.confirmed) return true;
if (h.expiresAt <= now || h.seats.some((s) => show.owner.get(s) !== h)) return false;
h.confirmed = true;
return true;
}
seatMap(showId: string, now: number): string {
const show = this.shows.get(showId)!;
return show.seats.map((s) => {
const h = show.owner.get(s);
if (!h || !show.taken(s, now)) return `${s}:free`;
return `${s}:${h.confirmed ? "booked" : "held"}(${h.user})`;
}).join(" ");
}
}
async function main(): Promise<void> {
const svc = new BookingService();
svc.addShow(new Show("S1", ["A1", "A2", "A3", "A4"]));
const h1 = svc.hold("S1", ["A1", "A2"], "ana", 0)!;
console.log("ana holds A1 A2:", h1);
console.log("ben holds A2 A3:", svc.hold("S1", ["A2", "A3"], "ben", 10) ?? "None");
const h2 = svc.hold("S1", ["A3", "A4"], "ben", 10)!;
console.log("ben holds A3 A4:", h2);
console.log("ana confirms at 100 s:", svc.confirm(h1, 100) ? "True" : "False");
console.log("ben confirms at 400 s:", svc.confirm(h2, 400) ? "True" : "False");
console.log("cara holds A3 at 401 s:", svc.hold("S1", ["A3"], "cara", 401) ?? "None");
console.log(svc.seatMap("S1", 401));
const results = await Promise.all(Array.from({ length: 8 }, async (_, i) => svc.hold("S1", ["A4"], `u${i}`, 500)));
console.log("8 buyers race for A4 at 500 s, holds granted:", results.filter((r) => r !== null).length);
}
void main();import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
class Hold {
final String id, user;
final List<String> seats;
final long expiresAt;
boolean confirmed;
Hold(String id, String user, List<String> seats, long expiresAt) {
this.id = id;
this.user = user;
this.seats = List.copyOf(seats);
this.expiresAt = expiresAt;
}
}
class Show {
final String id;
final List<String> seats;
final Map<String, Hold> owner = new ConcurrentHashMap<>(); // seat -> the hold or booking that has it
Show(String id, List<String> seats) {
this.id = id;
this.seats = seats;
}
boolean taken(String seat, long now) {
Hold h = owner.get(seat);
return h != null && (h.confirmed || h.expiresAt > now); // an expired hold counts as free
}
}
class BookingService {
static final long HOLD_SECONDS = 300;
private record Entry(Show show, Hold hold) {}
private final Map<String, Show> shows = new ConcurrentHashMap<>();
private final Map<String, Entry> holds = new ConcurrentHashMap<>();
private final AtomicInteger ids = new AtomicInteger();
void addShow(Show s) {
shows.put(s.id, s);
}
/** All or nothing: either every seat is held for this user, or none is. Locks only this show. */
String hold(String showId, List<String> seats, String user, long now) {
Show show = shows.get(showId);
synchronized (show) {
for (String s : seats) if (!show.seats.contains(s) || show.taken(s, now)) return null;
Hold h = new Hold("H" + ids.incrementAndGet(), user, seats, now + HOLD_SECONDS);
for (String s : seats) show.owner.put(s, h);
holds.put(h.id, new Entry(show, h));
return h.id;
}
}
/** Called after payment succeeds. Fails if the hold expired first. */
boolean confirm(String holdId, long now) {
Entry e = holds.get(holdId);
synchronized (e.show()) {
Hold h = e.hold();
if (h.confirmed) return true;
if (h.expiresAt <= now || h.seats.stream().anyMatch(s -> e.show().owner.get(s) != h)) return false;
h.confirmed = true;
return true;
}
}
String seatMap(String showId, long now) {
Show show = shows.get(showId);
return show.seats.stream().map(s -> {
Hold h = show.owner.get(s);
if (h == null || !show.taken(s, now)) return s + ":free";
return s + ":" + (h.confirmed ? "booked" : "held") + "(" + h.user + ")";
}).collect(Collectors.joining(" "));
}
}
public class Main {
static String py(Object o) {
return o == null ? "None" : o instanceof Boolean b ? (b ? "True" : "False") : o.toString();
}
public static void main(String[] args) throws InterruptedException {
BookingService svc = new BookingService();
svc.addShow(new Show("S1", List.of("A1", "A2", "A3", "A4")));
String h1 = svc.hold("S1", List.of("A1", "A2"), "ana", 0);
System.out.println("ana holds A1 A2: " + py(h1));
System.out.println("ben holds A2 A3: " + py(svc.hold("S1", List.of("A2", "A3"), "ben", 10)));
String h2 = svc.hold("S1", List.of("A3", "A4"), "ben", 10);
System.out.println("ben holds A3 A4: " + py(h2));
System.out.println("ana confirms at 100 s: " + py(svc.confirm(h1, 100)));
System.out.println("ben confirms at 400 s: " + py(svc.confirm(h2, 400)));
System.out.println("cara holds A3 at 401 s: " + py(svc.hold("S1", List.of("A3"), "cara", 401)));
System.out.println(svc.seatMap("S1", 401));
AtomicInteger wins = new AtomicInteger();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 8; i++) {
String user = "u" + i;
threads.add(Thread.ofPlatform().start(() -> { if (svc.hold("S1", List.of("A4"), user, 500) != null) wins.incrementAndGet(); }));
}
for (Thread t : threads) t.join();
System.out.println("8 buyers race for A4 at 500 s, holds granted: " + wins.get());
}
}Output (the same in all three languages)
ana holds A1 A2: H1 ben holds A2 A3: None ben holds A3 A4: H2 ana confirms at 100 s: True ben confirms at 400 s: False cara holds A3 at 401 s: H3 A1:booked(ana) A2:booked(ana) A3:held(cara) A4:free 8 buyers race for A4 at 500 s, holds granted: 1
7. Concurrency and edge cases
- Check-then-act is the bug. “Is A2 free? Yes. Mark A2 held.” Two threads can both pass the check. The check and the mark must be one atomic step: a lock in one process, a conditional update or a unique constraint in a database.
- Several servers can't share an in-memory lock. Put the seat state in the database (
UPDATE seats SET hold = ? WHERE id IN (…) AND (hold IS NULL OR expires_at < now), checking that every row was updated) or use a distributed lock with care. - Payment after expiry is a real case: confirm must re-check the hold, and the payment must be refunded if it fails.
- Lock order: one lock per show, held briefly, never while calling the payment service.
8. Follow-up questions
What interviewers ask once the core works, and a short answer for each.
- How would you handle a very popular show?
- A virtual waiting room in front of seat selection, so only a few hundred users choose seats at a time; see the flash-sale design (D24).
- Why not lock individual seats?
- Multi-seat holds would need several locks, in a fixed order to avoid deadlock. One lock per show is simpler and contention is short.
- Optimistic instead of pessimistic?
- Version each seat; update only if the version hasn't changed, and retry or fail on conflict. Good when conflicts are rare.
- Release seats when the user closes the tab?
- Best effort only (an unload call can be lost); expiry is the guarantee.
9. Common mistakes
Checking availability, then booking in a separate stepThe classic double-booking race.
A cleanup job as the only way holds expireIf the job is late, seats stay blocked. Treat expired holds as free when reading.
Holding the lock while calling paymentEvery other buyer for that show waits seconds.
10. Check yourself
What actually prevents double booking?
Two checks can both pass; only atomicity stops the second mark.
A hold expired at 310 s and confirm is called at 400 s. Result?
Confirm re-checks the expiry.
Why one lock per show rather than one global lock?
Contention is per show.
Your turn
- Database version (15 min). Write the SQL update that holds seats atomically and detects partial success.
- Add a waiting room (10 min). Admit a fixed number of users per minute to seat selection.