Frontend System Design: Seat Booking (BookMyShow / Cinema)

interviewNovember 02, 2026ยท 11 min read

"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:

  1. 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.
  2. 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.
  3. 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.
  4. 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 now
  • HELD โ€” 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 selected
  • pending โ€” optimistically selected, hold request in flight
  • held โ€” server confirmed; the checkout timer starts now
  • conflict โ€” 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 pending seat 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.
  • conflict is 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 only

What 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 expiresAt in 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 setInterval extending 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 from lastRev; 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 rev beats 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-disabled states, 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 rev and applied idempotently โ€” reconnects replay, duplicates are no-ops.
  • A delta never overwrites a pending local transition unless its rev wins โ€” 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 โ†’ held with conflict rollback); 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

Donate
ยฉ 2024, Built with Gatsby