"Design a seat selection screen for a movie ticket booking app" sounds like the easiest question in the loop โ until the interviewer asks: "two users tap the same seat at the same moment. What happens?"
The junior answer assumes the UI is the system. The senior answer knows a seat map is a distributed concurrency problem wearing a UI: hundreds of users are racing for the same scarce inventory, every second of hesitation on the payment page costs someone a seat, and the map on screen is a cached claim about the world, not the world.
A seat booking screen is five systems stacked: a state model (what states can a seat be in, and who owns the transitions?), a hold protocol (how do you reserve inventory without selling it twice?), a selection state machine (how does tapping feel instant without lying?), a real-time availability channel (how does the map stay honest when someone else wins a seat?), and a payment handoff (how do you survive a 3-minute payment against a 10-minute hold?). Let's design all five.
Requirements โ the questions that split junior from senior
Before drawing anything, ask:
- Venue scale? A 200-seat cinema hall or a 60,000-seat stadium? This changes the rendering strategy (DOM grid vs canvas layers) and the fan-out design. A movie app and a stadium app share a data model but not a render pipeline.
- Hold semantics? When I tap a seat, is it mine for 30 seconds, 10 minutes, or until I leave the page? The TTL is the heart of the design โ everything else hangs off it.
- Real-time availability? Does the map live-update as other people buy, or refresh on entry? Real-time means a push channel and a delta protocol.
- Seat types and pricing? Recliner vs classic, row-based pricing? Prices render per seat but must be computed server-side.
- Who wins a contested seat? Two users, one seat โ first hold wins, not first tap. Say that out loud; it anchors every later decision.
- Payment failure? What happens to a held seat when the card is declined, the app crashes mid-payment, or the user simply never pays?
I'll assume the full production version: 200โ400 seat halls, per-seat pricing, live availability, 10-minute holds with extension, a BookMyShow-style UPI/card payment handoff, and honest oversell prevention.
The naive answer โ and exactly where it dies
The naive answer: fetch GET /shows/:id/seats, render a grid where users toggle seats locally, then POST /book with the chosen seat ids at checkout.
Four ways this dies in production:
- Oversell by stale inventory. The list was fetched 40 seconds ago. Two buyers both see A12 green, both POST, and if the backend is equally naive, both payments succeed. In a hit show, thousands of users hold the same render in memory โ the map is a lie the moment it renders, and every second it stays stale it lies harder.
- No hold means checkout roulette. Without a server-side hold, the seat is only "reserved" when the booking succeeds โ after payment. Two people pay for A12 and one paid real money for a seat that no longer exists. Refund flows are how booking apps lose customers โ and how candidates lose the interview.
- A hold without a TTL means dead inventory. The inverse bug: seats get reserved in sessions that die silently โ tab closed, app killed โ and nobody ever releases them. The show "sells out" in phantom inventory while real buyers stare at a map of grey.
- Client-trusted state. Price, seat type, and totals computed on the client from the seat list are wrong the moment a price changes mid-selection โ or the client edits state, which is one devtools panel away.
The senior tell: selection is a claim on shared, scarce state. The server is the only arbiter; the UI's job is to make claims feel instant and honest.
Data model โ seat states and who owns transitions
The mental model that separates this design from a checkbox grid: selection is not client state โ selection is your client's view of a server-side hold.
A seat moves through:
AVAILABLEโ claimable right nowHELDโ claimed by a hold with an expiry (someone's checkout, possibly yours)SOLDโ paid, permanent
One seat record in the show inventory:
{
"showId": "shw_9f2c",
"id": "A-12",
"section": "classic",
"row": "A",
"num": 12,
"priceMinor": 24000,
"currency": "INR",
"state": "HELD",
"rev": 411,
"hold": {
"id": "hld_7a1",
"sessionId": "sess_5d2",
"expiresAt": "2026-11-02T10:14:30Z"
}
}Three decisions worth stating explicitly:
- The hold is a server resource with an id, a session binding, and an expiry. The client never mutates seat state โ it requests holds and receives state back. The UI renders the server's answer, plus its own optimistic layer on top.
- Expiry is server-computed and server-enforced โ stored as an absolute timestamp, checked lazily on read and released by a sweeper for fan-out. Never trust client clocks; this is the same date-math discipline as the date range picker, except here a wrong clock costs money.
- Every seat carries a monotonic
rev. It's what makes delta updates idempotent and lets a reconnecting client resync without a full refetch.
Selection state machine โ optimistic by design
Tapping a seat must feel instant. The truth is a round trip. This is the same optimistic pattern as the kanban board's drag and the email client's mark-read: transition local state immediately, then confirm or roll back.
The selection states per seat:
idleโ not selectedpendingโ optimistically selected, hold request in flightheldโ server confirmed; the checkout timer starts nowconflictโ server rejected: someone else got there first
type SeatUIState = "idle" | "pending" | "held" | "conflict";
type Action =
| { type: "TAP"; seatId: string }
| { type: "HOLD_OK"; seatId: string }
| { type: "HOLD_TAKEN"; seatId: string }
| { type: "HOLD_FAIL"; seatId: string } // network error / timeout
| { type: "RELEASE"; seatId: string }; // user deselects
function selectionReducer(
state: Record<string, SeatUIState>,
action: Action
) {
switch (action.type) {
case "TAP": {
// Only idle seats can enter pending โ second taps are no-ops
if (state[action.seatId] !== "idle") return state;
return { ...state, [action.seatId]: "pending" };
}
case "HOLD_OK":
return { ...state, [action.seatId]: "held" };
case "HOLD_TAKEN":
// Server says no: mark conflict so the map can show "just taken"
return { ...state, [action.seatId]: "conflict" };
case "HOLD_FAIL":
// Roll back honestly โ the seat may still be free
return { ...state, [action.seatId]: "idle" };
case "RELEASE":
return { ...state, [action.seatId]: "idle" };
}
}Rules the naive implementations miss:
- Cap selections on both sides. The client cap (say, 10 tickets) is UX; the server cap is the law. The client cap keeps the UI honest fast; the server cap survives modified clients.
- A second tap on a
pendingseat is a defined no-op, not an accidental double-hold. In-flight requests are guarded by the state machine, not by luck. - The checkout timer starts on
HOLD_OK, not on first tap. Otherwise slow networks silently burn the user's hold window. conflictis a moment, not a dead end. Shake the seat, mark it taken in the canonical layer, toast "A12 was just booked โ nearest available is A14" (you have the map; use it). Turning a failure into a reroute is product thinking interviewers remember.
The hold protocol โ idempotent, TTL-bound, session-bound
The core API. Three invariants: idempotent (retry-safe), TTL-bound (self-healing inventory), session-bound (only the holder can extend or release).
// POST /shows/:id/holds
// body: { seatIds: ["A-12"], idempotencyKey, sessionId }
// โ 201 { holdId, seats, expiresAt }
// โ 409 { conflicts: [{ id: "A-12", state: "HELD", expiresAt }] }
// POST /holds/:holdId/extend โ 200 { expiresAt } // holder only, capped
// DELETE /holds/:holdId โ 204 // holder onlyWhat makes the answer senior:
- Idempotency key on hold creation. A retry over a flaky mobile network must never double-hold a seat or double-count the selection. This is the same discipline as the email client's durable outbox โ every mutation is safe to replay.
- The 409 returns the winner's expiry. "A12 is held, frees up in 4m30s" turns a dead end into a decision โ wait or pick another. Most candidates return a bare error; the expiry is the detail that shows you've thought about the actual user.
- Expiry is lazy and swept. Reads treat
expiresAtin the past as AVAILABLE; a sweeper batch-releases expired holds and emits the deltas that keep other clients honest. Lazy-only leaks phantom inventory into the real-time channel; sweep-only wastes work on every read. - Extend on focus, not on a blind timer. A user returning to a backgrounded tab extends the hold once โ not five times, once per visibility change, capped overall. A blind
setIntervalextending against a dead tab is exactly the kind of bug the interviewer is fishing for. - Payment beats the TTL. If the hold expired 20 seconds ago but the payment succeeded, the booking wins โ payment is a stronger claim than a hold. Backend-enforced grace window; the frontend's job is to know this invariant exists, because it's the favorite follow-up question.
Real-time availability โ deltas, not full maps
When someone's hold expires or a sale completes, every other buyer's map must update โ or it's lying again. Push the change over SSE or WebSocket (the websockets deep-dive covers the channel tradeoffs) as deltas keyed by seat revision:
{
"type": "seats.delta",
"showRev": 412,
"seats": [
{ "id": "A-12", "state": "SOLD", "rev": 411 },
{ "id": "B-03", "state": "AVAILABLE", "rev": 409 }
]
}Client rules:
- Apply deltas idempotently by
rev. A reconnect replays fromlastRev; a repeated delta is a no-op. Without this, one flaky reconnect turns the map into a flickering mess of stale states. - A delta never fights a pending tap. If a delta arrives for a seat the user is actively holding, it applies only when its
revbeats the local transition's. Last-writer-wins wipes are the collaborative-editor bug all over again โ except here the wipe can oversell a seat. - Debounce repaints. A hit show can emit hundreds of deltas per minute. Batch them into a single render pass โ the same rAF batching discipline as the date range picker's hover previews, now applied to a channel instead of a pointer.
- Degrade gracefully: SSE, then long-poll, then refresh-on-focus. A booking flow that hard-requires WebSockets dies on corporate proxies and subway Wi-Fi โ precisely where tickets are bought.
Full-map fetches are for first load and reconnects. Steady state is deltas.
Rendering the map โ 300 seats, then 60,000
- A cinema hall is a DOM grid. 200โ400 seats fit comfortably as CSS grid with semantic buttons. Honest calibration: you don't need canvas until ~5,000 seats. Volunteering when you wouldn't optimize is a senior signal.
- A stadium is section-first. Render the zoomed-in section at DOM fidelity; the zoomed-out overview is a lightweight canvas or SVG layer with hit-testing. Nobody needs 60,000 live nodes.
- Drag-to-select multi-seat: pointer events with capture (the kanban drag discipline), highlight the swept rect live, and hold all swept seats in one batched request on release โ N holds in flight is N chances to land in a half-selected mess.
- Accessibility is load-bearing here, not decoration. Seats are buttons in a grid with spoken labels ("Row A, seat 12, classic, 240 rupees, available"); held and sold are
aria-disabledstates, never color alone; row headers are real headers. A seat map is one of the few screens where screen-reader UX is genuinely a product requirement โ and it's a differentiator almost nobody rehearses.
Payment handoff โ the TTL's final boss
Checkout hands the hold a countdown. Payment providers take anywhere from 30 seconds to 3 minutes. Designs that survive that gap:
- The hold TTL covers payment โ 8โ10 minutes for cinema, or 3โ5 with one capped auto-extend if the product wants visible urgency ("4 minutes left!").
- Extend on payment-page mount and on visibility focus. Backgrounded tabs shouldn't burn the clock; foregrounded ones extend exactly once.
- The booking op is idempotent:
POST /bookings { holdId, paymentRef, idempotencyKey }. Payment success against an expired hold hits the grace window and wins. Payment success against someone else's booking triggers a full instant refund โ and that's a backend monetary invariant, not a frontend hope. The frontend's job is the recovery screen: honest, specific, and one tap from re-booking. - Failure paths roll back visibly. Card declined doesn't silently drop the hold โ it releases after a grace period ("we're holding your seats for 5 more minutes") with a clear retry. Silence is how trust dies in payments.
Interview gotchas โ the quick-fire round
- First hold wins, never first tap โ the UI's optimistic selection is a claim, not a fact.
- The checkout timer starts at
HOLD_OK, not at tap โ slow networks shouldn't burn the user's window. - Every hold has a TTL โ a hold without expiry is a memory leak made of seats.
- Expired holds are released lazily on read and by a sweeper โ one without the other leaks phantoms or wastes reads.
- Deltas are keyed by seat
revand applied idempotently โ reconnects replay, duplicates are no-ops. - A delta never overwrites a pending local transition unless its
revwins โ the collaborative-editor wipe, with money attached. - Hold creation takes an idempotency key โ mobile networks retry, and retry must be safe.
- A 409 returns the winner's expiry โ "frees up in 4m30s" is a decision, a bare error is a dead end.
- Seats are labeled buttons in a grid role โ a seat map is one of the few places screen-reader UX is a product requirement.
Putting it together
A production seat booking design:
- Data model: seats with
AVAILABLE / HELD / SOLD; holds as server resources with id, session binding, absolute expiry, and monotonic revisions - Selection: optimistic per-seat state machine (
idle โ pending โ heldwithconflictrollback); selection caps on both sides; timer starts on confirmation - Inventory safety: idempotent holds, TTL-bound with lazy + swept release, first-hold-wins arbitration, payment-beats-TTL grace window as a backend monetary invariant
- Availability: delta push over SSE/WebSocket keyed by
rev, applied idempotently, repainted in batched render passes, with long-poll fallback - Rendering: honest DOM grid at cinema scale; section-first canvas at stadium scale; drag-select as one batched hold
- Payments: TTL sized to cover the provider, extend-on-focus, idempotent booking op, visible failure rollbacks
- A11y: spoken seat labels, grid semantics, state beyond color
The seat grid is the easy 20%. The product is the hold protocol โ a short-lived, self-healing claim on scarce state that keeps hundreds of concurrent buyers honest without a single oversell. Design the claim, and the UI designs itself.
The Series: Frontend System Design Interview Questions
- The RADIO Framework
- Design an Autocomplete / Typeahead
- Design a News Feed โ Facebook / Twitter
- Design a Chat Application (Messenger/Slack)
- Design an Image Carousel
- Design a Data Table
- Design Infinite Scroll
- Design a Star Rating Widget
- Design a Kanban Board
- Design Nested Comments
- Design Undo / Redo
- Design a Spreadsheet
- Design a Date Range Picker
- Design an Email Client (Gmail)
- Design a Collaborative Editor (Google Docs)
- Design a Seat Booking Flow (this article)