cf.completefrontend
My progress
SYSTEM DESIGN · #94

Design a seat selection map for events

An interactive map of a stadium or theater where fans pick exact seats from 5,000 to 80,000, while thousands of other buyers grab seats at the same moment. The hard parts are drawing and hit-testing tens of thousands of seats at 60 frames per second, keeping availability live during an on-sale rush, holding seats on the server with a timer, and giving keyboard and screen reader users an equal way to choose.

The question

Design the seat selection component for a ticketing site like Ticketmaster or SeatGeek. A fan opens an event, sees the venue map, zooms into a section, picks up to 8 seats, sees the full price, and goes to checkout. Venues range from a 1,500-seat theater to an 80,000-seat stadium.

When a big tour goes on sale, hundreds of thousands of people want the same seats in the same minute. Seats disappear while people are looking at them. Walk me through how the map is drawn, how it stays up to date, how a seat is reserved while the fan pays, and how someone who cannot use a mouse or cannot see the map buys a ticket.

Real-life examples: Ticketmaster interactive seat map, SeatGeek and AXS venue maps, Eventim and BookMyShow seat layouts, Airline seat maps at check-in

Requirements: what to ask before drawing anything

A seat map looks like a picture with dots on it. It is really four things in one component: a drawing engine for tens of thousands of shapes, a live view of stock that changes every second during a sale, a reservation flow where a click locks real inventory on the server, and an accessible chooser for people who cannot see or point at the picture.

Spend the first five minutes on the questions whose answers change the design. The table lists the ones this guide asks, the answer it assumes, and what each answer changes.

QuestionAnswer we assumeWhat it changes
How big are the venues?From a 1,500-seat theater to an 80,000-seat stadium with 150 or more sections.80,000 seats is too many for one DOM element each. Rendering strategy becomes a core decision.
Pick your own seat, or best available only?Both. Most fans pick on the map; a best available button picks N seats together for them.We need a seat picker and a client or server search for adjacent seats.
How busy is an on-sale?Big tours draw hundreds of thousands of fans in the first minutes. One section can lose most of its seats in under a minute.Live availability, a waiting room in front of the map, and fast rollback when a seat is taken.
How long is a seat held?The server holds selected seats for 10 minutes, and the fan can extend once in the last minute.A server-side hold, a visible countdown, and an expiry path mid-checkout.
How many seats per order?Up to 8 for normal events, sometimes 4 or 2 for high-demand ones. The event sets the limit.A maxSeats prop and a server check, since the client limit is easy to bypass.
Which devices?More than half of traffic is phones, often on a busy cell network inside or near the venue.A list-first mobile flow, big tap targets, and a small first download.
Who uses it besides sighted mouse users?Keyboard users, screen reader users, and fans who need wheelchair spaces and companion seats.An equal non-visual way to choose, and accessible seats that follow the rules of the law.
Does price vary by seat?Yes. Each seat has a price level, and prices can change during the sale (dynamic pricing).Prices are live data, not part of the static venue file.

Functional requirements

  • Show the venue map: stage, sections, and seats colored by price level, with taken seats greyed out.
  • Zoom and pan with mouse wheel, trackpad pinch, touch pinch, and the keyboard. Click a section to zoom into it.
  • Hover or focus a seat to see its section, row, seat number, price, and tags such as aisle, obstructed view, or wheelchair space.
  • Select and deselect seats up to the event limit. Each selection places a hold on the server.
  • Show a cart with the held seats, the all-in price, and a countdown for the hold, with a way to ask for more time.
  • Offer best available: pick a quantity and price range, get N seats together, accept or try again.
  • Offer a list view of available seats, filterable by section, price, and accessibility needs, that works with a keyboard and a screen reader.

Non-functional requirements

  • Smooth: pan and zoom at 60 frames per second on a mid-range phone, so each frame has a budget of about 16 ms.
  • Fast first view: the overview map drawn within about 2.5 seconds on a 4G phone, the same bar as a good Largest Contentful Paint (LCP), the Core Web Vital for when the main content appears.
  • Fresh: a seat someone else takes should look taken within about 1 to 2 seconds.
  • Correct: the server is the only judge of who owns a seat. The client never sells a seat on its own.
  • Fair and resilient: the page survives a traffic spike 50 to 100 times the normal load, and bots do not get the seats first.
  • Accessible: WCAG 2.2 AA (the Web Content Accessibility Guidelines, level AA), including a hold timer people can extend.

Out of scope

Payment, the checkout form, ticket delivery, and resale are out of scope. The amazon and checkout-flow guides cover the checkout, and the travel-booking guide covers idempotent booking. We stop at the moment the fan presses Continue with held seats. We also skip the venue editor that staff use to draw venues; we only read its output.

Check your understanding · Exercise 1

Which answers from the interviewer change the design the most? Select all that apply.

What makes a seat map hard

Before choosing anything, name the properties of the problem. Every later decision traces back to one of these five.

  1. Many small targets. A stadium has up to 80,000 seats. At the overview zoom, each seat is smaller than one screen pixel. Zoomed in, each seat is a 20 to 30 pixel circle the fan must hit with a thumb. One drawing approach rarely fits both ends.
  2. Two kinds of data with very different lifetimes. The shape of the venue (where each seat is) changes a few times a year. Whether a seat is free changes many times per second during an on-sale. Treating them as one blob either makes the map slow to load or makes it stale.
  3. A race with strangers. The fan sees a seat as free, but by the time the click reaches the server, someone else may own it. Every selection is a bet that can lose, so the interface must handle no as a normal answer.
  4. Time pressure. A hold expires. The fan is reading prices, asking a friend, typing a card number, and the clock runs. The timer must be visible, fair, and extendable for people who need more time.
  5. A picture is not an interface for everyone. A screen reader cannot read 80,000 circles, and a keyboard user cannot tab through them. The component needs an equal non-visual path, not just labels on dots.

Here is the screen we are designing on desktop. The rest of the guide builds each part.

Wireframe of the seat selection screen. A toolbar holds the quantity (2), a price filter (up to 150 dollars), an accessibility filter, and a Map or List toggle. Below it, the venue map shows the stage at the top and six sections, 101 to 103 and 201 to 203, with section 102 highlighted, plus zoom in and zoom out buttons and a legend. On the right, the cart lists two held seats in section 102, row F, seats 7 and 9, shows the total of 190 dollars all-in, a timer with 9 minutes 41 seconds left, and a Continue button.Event page: choose your seatsQty 2Up to $150AccessibilityMap | ListStage101102103201202203+-legend: $95, $150, takenYour seats102 F7 $95102 F9 $95$190 all-in09:41 leftContinue
Every later chapter designs one of these parts: the map's drawing and zoom, the live colors, the cart's holds and timer, and the Map or List toggle that opens the accessible list view.

The map takes most of the width. A toolbar above it holds the quantity, price filter, and accessibility filter, and a legend explains the colors.

The cart panel on the right lists the held seats, shows the all-in price (the total including every mandatory fee), and runs the hold countdown. A toggle switches the map to a list view, which is a full alternative, not a fallback.

Think first

Is an airline seat map the same problem as a stadium seat map?

The venue data model: static geometry, event layer, live status

Start with the data, because the rendering and real-time choices depend on its shape and size. A seat map needs to know, for every seat: where it is, what it is called, what it costs, what kind of seat it is, and whether it is free right now.

The naive approach: one big JSON per event

The first idea is one endpoint, GET /events/123/seats, returning every seat with all its fields. Each seat looks like { id, section, row, number, x, y, price, status, tags }, about 120 bytes of JSON. For an 80,000-seat stadium that is about 9.6 MB before compression and roughly 1 to 1.5 MB after Brotli compression (an estimate: repeated keys compress very well).

The size is a problem, but the real problem is that status lives in the same file as x and y. Status changes every second during a sale, so the file can never be cached for long. Every fan re-downloads 1 MB of coordinates that have not changed since the venue was built, and during an on-sale with 200,000 fans that is about 200 GB of mostly identical bytes from the origin (the company's own servers, behind a content delivery network or CDN).

Decision How do we split the seat data?

One blob per event

Every field of every seat in one response, fetched on load and re-fetched to refresh.

Pros

  • One request, simple code.

Cons

  • Cannot be cached because status is inside.
  • Refreshing status means re-downloading geometry.
  • 1 MB or more on every refresh for a stadium.

Split by how often data changes

Venue geometry: immutable, versioned URL, cached for a year.

Event layer: price level per seat and which seats are on sale, cached for minutes.

Live status: a small snapshot plus a stream of changes.

Pros

  • Geometry comes from the CDN or the browser cache, often with zero origin load.
  • Status is tiny: a few KB.
  • Each layer has a cache policy that fits it.

Cons

  • Three requests and a join on the client by seat index.
  • Versions must match: the event layer names the geometry version it expects.

Split, plus per-section seat files

The overview loads section outlines only (about 50 KB).

Each section's seats load when the fan zooms in or hovers it.

Pros

  • Fastest first view for huge venues.
  • Phones never download sections they do not open.

Cons

  • A short loading flash when zooming into a new section.
  • Best available across the whole venue needs all seats or a server call.

What we pick: Split by how often data changes. For venues above about 20,000 seats, also split geometry per section and prefetch the sections near the one the fan is looking at. The overview never needs individual seats, so there is no reason to block the first paint on them.

The seat data split into three layers. Venue geometry (sections, rows, and tags) is cached for a year at an immutable URL. The event layer (price levels and which seats are on sale) is cached for about a minute. Live status is a 20 KB snapshot at 2 bits per seat with a sequence number, followed by diffs. All three flow into the client's seat store, which joins them by seat index, so seat index 20417 means seat 112-F-7.Venue geometrychanges yearly: cache 1 yearEvent layerprices, on sale: cache 1 minLive status20 KB snapshot + diffsSeat storetyped arrays by indexindex 20417= seat 112-F-7join by index
The split follows how often each piece changes. Only the bottom layer is live, and it is by far the smallest.

Making geometry small

Most seats sit in straight or curved rows at even spacing. Instead of storing 30 seat positions, store the row once: a start point, an end point (or an arc), a seat count, and the first seat number. The client computes positions when it loads the section.

A typical stadium row of 25 seats drops from about 3 KB of JSON to about 100 bytes. Seats that break the pattern (a missing seat for a pillar, a wheelchair space) are listed as exceptions.

Inside the client, keep seats in flat typed arrays indexed by a number from 0 to N minus 1: a Float32Array for x and y (8 bytes per seat, 640 KB for 80,000 seats), a Uint8Array for status, a Uint16Array for the price level. Typed arrays are compact, fast to loop over, and can be sent to a worker without copying. The human id such as 112-F-7 maps to the index through a Map built once.

ts
// Layer 1: venue geometry, immutable URL, cached for a year
type VenueGeometry = {
  venueId: string; version: number;
  sections: { id: string; name: string; outline: [number, number][];
    rows: { name: string; from: [number, number]; to: [number, number];
      bend: number; count: number; firstSeat: number; firstIndex: number }[] }[];
  seatTags: Record<number, ("aisle" | "wheelchair" | "companion" | "obstructed" | "limited-legroom")[]>;
};

// Layer 2: event layer, cached for minutes
type EventLayer = {
  eventId: string; geometryVersion: number; maxSeats: number;
  priceLevels: { id: number; label: string; allInCents: number; color: string }[];
  seatPriceLevel: number[]; // index -> price level id, 0 = not on sale
};

// Layer 3: live status, snapshot then diffs
type StatusSnapshot = { eventId: string; seq: number; status: string }; // base64, 2 bits per seat
The three layers, joined by seat index

The status snapshot is the smallest piece. Each seat is in one of four states (available, held by someone, sold, not for sale), which fits in 2 bits. 80,000 seats times 2 bits is 20 KB raw, often much less after compression because sold seats come in long runs. The seq number lets the client line up the snapshot with the stream of changes that follows (the next chapters use it).

Check your understanding · Exercise 2

Which statements about the split data model are true? Select all that apply.

Rendering tens of thousands of seats

This is the decision interviewers push on hardest. The goal from the requirements: pan and zoom at 60 frames per second on a mid-range phone, so drawing, input handling, and any status updates must fit in about 16 ms per frame, and less in practice because the browser needs a few ms of its own.

The naive approach: one SVG element per seat

The obvious build is an svg with one circle per seat, each a React component with its own click handler. For a 1,500-seat theater this works well: the DOM handles hover, focus, and clicks for free, and the browser draws crisp vectors at any zoom.

Now load an 80,000-seat stadium. The browser holds 80,000 extra DOM nodes, each with style, layout boxes, and event plumbing, and React keeps 80,000 component instances. A status update that touches 300 seats makes React compare all of them unless every seat is carefully memoized.

And when the fan zooms, the browser repaints every circle in view. On a mid-range phone, those repaints can take well over 16 ms per frame (our estimate: tens of milliseconds at full stadium size), so zoom stutters exactly when the fan is trying to aim.

Decision How do we draw the seats?

SVG, one element per seat

Each seat is a circle or use element. CSS classes set the color by status.

The DOM does hit testing: clicks land on the right element.

Pros

  • Simplest code. Crisp at every zoom.
  • Free hit testing, hover, and focus.
  • Easy to style and test.

Cons

  • DOM size grows with seats: tens of thousands of nodes cost memory and paint time.
  • Zoom repaints every visible element.
  • Updates must be surgical or React re-renders a huge tree.

Canvas 2D

One canvas element. A draw loop paints visible seats each frame the view changes.

Hit testing is our own math: screen point to world point to nearest seat.

Pros

  • Cost depends on seats drawn, not seats existing.
  • Thousands of circles batched by color draw in a few ms.
  • One DOM node.

Cons

  • We write hit testing, hover, and redraw logic.
  • Nothing inside is visible to assistive technology.
  • Must handle devicePixelRatio for sharp output.

WebGL

Upload seat positions once to the GPU. Draw all seats with one instanced draw call; status is a small texture or buffer.

Pros

  • Handles hundreds of thousands of seats at full frame rate.
  • Smooth zoom with no redraw cost on the CPU.

Cons

  • Much more code: shaders, text rendering, context loss.
  • Usually more than a seat map needs: with level of detail, only a few thousand seats are drawn at once.
  • Same accessibility gap as canvas.

Hybrid with level of detail

SVG for the venue outline, stage, section shapes, and labels: a few hundred elements.

Seats appear only when zoomed in enough to see them, drawn on a canvas layer for big venues or as SVG for small ones.

Only seats inside the viewport are drawn.

Pros

  • The overview is light and crisp.
  • Zoomed in, only a few thousand seats are visible, so either renderer is fast.
  • Section shapes keep DOM hit testing and hover.

Cons

  • Two layers that must share one camera transform.
  • More states to test: which zoom shows what.

What we pick: The hybrid. Sections are SVG, because there are few of them and they need hover, clicks, and labels. Seats appear only when the camera is close enough, and only the visible ones are drawn: on a canvas layer for venues above about 5,000 seats, and as SVG below that (a starting threshold to tune by profiling on real phones). The seat renderer is a prop, so a theater and a stadium share the component. WebGL is the upgrade path if we ever show 3D views or profiling shows canvas cannot keep up.

Level of detail: what to draw at each zoom

Level of detail means drawing less when things are small. The rule uses the size of one seat on screen: seat size in venue units times the camera scale. The thresholds below are estimates that work as a starting point; tune them with real devices.

Seat size on screenWhat we drawWhat a click does
Under 2 pxSection shapes only, filled by price level, dimmed by share of seats left (for example 0 to 5 percent left looks almost empty).Zoom to the clicked section.
2 to 8 pxSeats as tiny squares, colored by status, no outlines or numbers.Zoom to the section around the point.
8 to 20 pxSeats as circles with status colors and a ring on selected seats.Select the nearest seat (with a generous hit radius on touch).
Over 20 pxPlus seat numbers, row labels, and icons such as the wheelchair symbol.Select the seat.
Level of detail in four panels, by seat size on screen. Under 2 pixels: only four section shapes, one greyed as sold out; a click zooms. 2 to 8 pixels: a dense grid of tiny squares, some taken; a click zooms. 8 to 20 pixels: a 5 by 5 block of seats with status; a click selects. Over 20 pixels: 9 large seats with numbers, one selected and one taken; a click selects.< 2 pxsectionsclick zooms2 to 8 pxtiny squaresclick zooms8 to 20 pxseatsclick selects> 20 px+ numbersclick selects5796810135
Each step draws more detail for fewer seats. At seat level only a few thousand seats fit on any screen, so the cost of a frame stops growing with the venue.

Level of detail also bounds the work. When seats are 8 px or more on a phone 400 px wide, at most a few thousand seats can be on screen at once, whatever the venue size. So frame cost depends on the screen, not the stadium. Culling (skipping what is off screen) is cheap: skip any section whose bounding box is outside the viewport, then skip seats outside it within visible sections.

Drawing seats on canvas fast

The biggest canvas cost is not the circles; it is switching styles and filling many tiny paths. So group seats by color and fill each group with one path: one beginPath, many arc calls, one fill. With six or seven colors, that is seven fills per frame instead of thousands.

ts
function drawSeats(ctx: CanvasRenderingContext2D, s: SeatStore, cam: Camera, visible: Int32Array) {
  const dpr = Math.min(window.devicePixelRatio, 2);
  ctx.setTransform(cam.scale * dpr, 0, 0, cam.scale * dpr, cam.x * dpr, cam.y * dpr);
  ctx.clearRect(-cam.x / cam.scale, -cam.y / cam.scale, cam.width / cam.scale, cam.height / cam.scale);
  const byColor = new Map<string, number[]>();
  for (const i of visible) {
    const color = colorFor(s.status[i], s.priceLevel[i], s.selected.has(i));
    let list = byColor.get(color);
    if (!list) byColor.set(color, (list = []));
    list.push(i);
  }
  for (const [color, seats] of byColor) {
    ctx.beginPath();
    for (const i of seats) {
      ctx.moveTo(s.x[i] + s.r, s.y[i]);
      ctx.arc(s.x[i], s.y[i], s.r, 0, Math.PI * 2);
    }
    ctx.fillStyle = color;
    ctx.fill();
  }
}
Draw visible seats, batched by color

Two details matter. First, size the canvas backing store (its pixel buffer) to CSS size times devicePixelRatio, capped at 2, or seats look blurry on high-density screens and memory explodes on 3x phones.

Second, never draw from an event handler. Input changes the camera and marks the canvas dirty; one requestAnimationFrame callback draws at most once per frame. The runtime-performance lesson explains why batching work into the frame matters.

Try the trade-offs below. Change the venue size and zoom, then compare the three strategies. The numbers are rough estimates for a mid-range phone, meant to show the shape of the curve, not exact timings.

Venue
Zoom

stadium, 80,000 seats, zoom on whole venue: about 80,000 seats in view. SVG per seat about 85.2 ms, canvas about 33 ms, hybrid about 2.5 ms per frame. Cheapest: Hybrid + LOD.

Estimated frame cost per strategy. stadium, 80,000 seats, zoom on whole venue: about 80,000 seats in view. SVG per seat about 85.2 ms, canvas about 33 ms, hybrid about 2.5 ms per frame. Cheapest: Hybrid + LOD. DOM nodes: SVG 80,150, canvas 1, hybrid 151. The budget line is at 16 ms.16 ms budgetSVG per seat85.2 ms80,150 DOM nodes, hit test: DOM does itCanvas, all seats33 ms1 DOM node, hit test: grid lookupHybrid + LOD2.5 ms151 DOM nodes, hit test: DOM, sectionsabout 80,000 seats in view, cheapest: Hybrid + LOD
Rough estimates for a mid-range phone, to show the shape of the trade-off, not exact timings. Green fits easily, hatched is tight, red misses the 16 ms frame budget.

Check your understanding · Exercise 3

A 60,000-seat stadium map stutters while zooming. Which changes help? Select all that apply.

Zoom, pan, hit testing, and the phone layout

The camera math is the same as on any infinite canvas: a camera with x, y, and scale, zoom around the pointer so the point under the finger stays put, and convert between screen and world coordinates. The figma and excalidraw guides derive it, and the google-maps guide adds tile levels. Here we focus on what is different about a seat map: it is a bounded picture inside a page, with a clear target (a section, then a seat).

One camera, two layers

The SVG layer (sections, labels) and the canvas layer (seats) must move as one. Keep a single camera in a small store. The SVG layer applies it as one transform on a root g element, so the browser moves one group instead of 300 sections, and the canvas layer applies the same numbers with setTransform before drawing.

Never store the camera in React state that re-renders the whole map. Update the SVG attribute and mark the canvas dirty directly, then let React re-render only the zoom buttons and the level-of-detail switch when they change.

Inputs, and not stealing the page scroll

  • Trackpad pinch arrives as wheel events with ctrlKey set to true in current browsers (Safari also fires its own gesturechange events, so ignore one of the two). Listen with { passive: false } and call preventDefault(), or the browser zooms the whole page instead of the map.
  • Plain mouse wheel is a choice. If the map fills the screen, wheel zooms. If the map sits inside a scrolling page, a wheel that zooms traps the reader; let wheel scroll the page and show a hint such as Hold Ctrl to zoom, the way embedded maps do.
  • Touch pinch and drag use Pointer Events with two active pointers. Set touch-action: none on the map only, so the rest of the page still scrolls normally.
  • Keyboard: when the map has focus, + and - zoom, arrow keys pan, and 0 resets. Visible zoom in, zoom out, and reset buttons serve people who cannot pinch or drag.
  • Click a section to zoom to it: compute its bounding box, fit it with padding, and animate the camera over about 300 ms. If prefers-reduced-motion is set, jump without animating.

Clamp the camera. The smallest scale fits the whole venue on screen; the largest makes a seat about 48 px wide, past which zooming adds nothing. Pan bounds keep at least part of the venue visible, so a fan never gets lost in empty space.

Hit testing seats without the DOM

On canvas we must answer: which seat is under this point? Looping over 80,000 seats on every pointermove can cost around a millisecond on a mid-range phone (an estimate) and wastes battery. A uniform grid (a simple spatial index) is enough: divide the venue into square cells about two seat spacings wide, and store the seat indexes in each cell once at load. A lookup converts the screen point to world units, finds its cell, and checks only that cell and its eight neighbors, a few dozen seats at most.

Hit testing with a uniform grid. The venue is divided into a 5 by 4 grid of cells, each holding a few seats. A tap lands in one cell; that cell and its eight neighbors are checked, shown shaded, with seat counts such as 6, 4, and 5. The other cells are skipped. Notes list the steps: convert the screen point to world units, find the cell, check the 9 cells, and pick the nearest seat within the hit radius, a few dozen seats checked instead of 80,000.6453tap5463one cell is about two seat spacings wide1. screen to world2. find the cell3. check 9 cells4. nearest within ra few dozen checks,not 80,000
The grid is built once at load. Each pointermove or tap then costs a few dozen distance checks, however big the venue is.
ts
function seatAt(px: number, py: number, cam: Camera, grid: SeatGrid, s: SeatStore, touch: boolean) {
  const wx = (px - cam.x) / cam.scale;
  const wy = (py - cam.y) / cam.scale;
  // At least 12 CSS px on touch (a 24 px target), but never past half the gap to the next seat
  const minRadius = (touch ? 12 : 4) / cam.scale;
  const radius = Math.min(Math.max(s.r, minRadius), s.spacing / 2);
  const cx = Math.floor(wx / grid.cell), cy = Math.floor(wy / grid.cell);
  let best = -1, bestD = radius * radius;
  for (let gx = cx - 1; gx <= cx + 1; gx++)
    for (let gy = cy - 1; gy <= cy + 1; gy++)
      for (const i of grid.at(gx, gy)) {
        const d = (s.x[i] - wx) ** 2 + (s.y[i] - wy) ** 2;
        if (d <= bestD) { best = i; bestD = d; }
      }
  return best; // -1 means no seat
}
Nearest seat under a pointer, with a touch-friendly radius

The hit radius matters more than the drawn size. A seat drawn at 10 px is hard to tap, but if the tap area grows to 24 px (the WCAG 2.2 minimum target size) and is capped at half the distance between seat centers, every tap lands on the closest seat with no overlap. When seats are packed tighter than 24 px apart, the list view and the phone section view (later in this chapter) offer the same choice with larger targets, which WCAG 2.5.8 accepts as an equivalent.

Also separate taps from pans: if the pointer moved more than about 8 px between down and up, it was a pan, not a selection.

Hover shows a small tooltip with section, row, seat, price, and tags. Throttle pointermove handling to one lookup per animation frame, and position the tooltip as a normal HTML element above the canvas so it can hold real text.

Check your understanding · Exercise 4

Which input choices are right for a seat map embedded in an event page? Select all that apply.

Phones: sections first, then seats

On a phone 390 CSS pixels wide, a 150-section stadium overview makes each section about 15 to 25 px across. Tapping the right one is hard, and individual seats are invisible. Copying the desktop map onto a phone gives a map that technically works and practically frustrates.

Decision How does the phone layout work?

The desktop map, scaled down

Same layout; the fan pinches to zoom.

Pros

  • One code path.

Cons

  • Many pinches before seats are tappable.
  • The cart panel has no room.

List first

A list of sections or price levels with seats left and the price range. Tapping opens that section's seats.

Pros

  • Fast, readable, accessible, and light on data.

Cons

  • Loses the sense of where you sit relative to the stage, which many fans care about.

Overview plus a bottom sheet, then a section view

A small overview map on top, a draggable bottom sheet below listing sections with prices.

Tapping a section in either place zooms into a section view where seats are at least 24 px.

Pros

  • Keeps the where am I picture and the speed of a list.
  • Seats are big enough to tap without pinching.

Cons

  • More layout states to build and test.

What we pick: Overview plus a bottom sheet, then a section view. The cart becomes a sticky bar at the bottom with the seat count, the all-in total, and the timer, and it expands into a sheet. Pinch still works for those who want it, but no task requires it.

Three phone screens. First, the overview: a small venue map with sections 111, 112, and 113 under the stage, and a bottom sheet listing sections with all-in prices and seats left: 112 at 95 dollars with 14 left, 113 at 80 dollars with 30 left, 201 at 60 dollars with 90 left. Second, the section view for section 112: a 4 by 4 grid of large seats, two selected and four taken, with a sticky bar showing 190 dollars and 9 minutes 41 seconds left. Third, the cart sheet: seats F7 and F9 at 95 dollars each, 190 dollars all-in, the timer, and a Continue button.overviewstage111112113112 $95 14 left113 $80 30 left201 $60 90 leftsection 112seats 32 px wide$190, 9:41cart sheetF7 $95F9 $95$190 all-in9:41 leftContinue1. pick a section2. tap seats3. review, continue
No step needs a pinch. A tap on a section opens seats big enough to tap, and the sticky bar keeps the total and the timer in view.

The phone layout changes more than the arrangement. It also changes what gets loaded, how much memory the canvas takes, and how the cart stays in view. Four details matter most.

  • Load less. On a phone, load section outlines first and seat geometry only for the section opened, plus its neighbors in the background.
  • Respect the network. Fans often buy near the venue or on a busy cell network. Keep the stream messages small and do not block the section view on the stream: render from the snapshot, then apply diffs.
  • Memory. The devicePixelRatio cap of 2 from the rendering chapter matters most here. A full-screen canvas at 3x on a large phone is about 1290 by 2796 pixels, roughly 14 MB of pixels for one layer.
  • Safe areas. The sticky cart bar respects env(safe-area-inset-bottom) (with viewport-fit=cover in the viewport meta tag) so it does not sit under the home indicator.

Check your understanding · Exercise 5

Which choices make seat selection work on a small phone? Select all that apply.

Live availability: seats taken while you look

During an on-sale, the map is a live view. If a fan spends 40 seconds choosing a seat that was sold 30 seconds ago, the click fails and trust drops. The requirement: a seat taken by someone else should look taken within 1 to 2 seconds.

The naive approach: re-fetch the snapshot every few seconds

Polling the 20 KB status snapshot every 5 seconds looks cheap. Multiply it out: 200,000 fans on one event is 4 GB per round, 800 MB per second, mostly identical bytes. A one-second CDN cache on the snapshot protects the origin, but the CDN still serves all of it, phones on cell networks pay for it, and the map is still up to 5 seconds stale. Most rounds carry only a few hundred changed seats out of 80,000.

Decision How do status changes reach the map?

Poll the full snapshot

GET /events/123/status every 3 to 5 seconds, cached at the CDN for 1 second.

Pros

  • Simplest. Every response is complete, so no gaps to repair.
  • CDN-friendly.

Cons

  • Large repeated downloads.
  • Staleness equals the interval.
  • Repaints the whole map each time unless we diff on the client.

Poll for changes since a sequence number

GET /events/123/changes?since=48211 returns only changes after that point.

The server rounds since to a bucket so the CDN can cache popular responses.

Pros

  • Small responses.
  • Still plain HTTP and cacheable.

Cons

  • Still interval-based staleness.
  • Many requests per fan that return nothing during quiet periods.

Server-Sent Events with diffs

One EventSource per tab to /events/123/stream.

The server batches changes every 500 ms and sends { seq, changes }.

Pros

  • Fresh within about a second.
  • One long request per fan, no polling overhead.
  • Built-in reconnect with Last-Event-ID.

Cons

  • Long-lived connections need servers built for them.
  • Server to client only (fine here: holds use normal requests).

WebSocket with diffs

A socket subscribed to the event channel, same diff messages.

Pros

  • Two-way, so holds could go over it too.
  • Good if the site already runs sockets.

Cons

  • Reconnect, heartbeats, and resume are our code.
  • Holds still want plain HTTP for retries and idempotency.

What we pick: Server-Sent Events carrying batched diffs, with the snapshot as the starting point and the repair tool. Updates only flow one way, which is exactly what SSE is for, and holds stay as plain HTTP requests where retries and status codes are simple. If SSE cannot connect (some corporate proxies buffer it), fall back to polling for changes since a sequence number. The server-sent-events and websockets lessons cover the wire details.

Snapshot plus diffs, joined by a sequence number

Every change to seat status on the server gets a number from an increasing sequence for that event. The snapshot says which number it reflects, and each stream message carries the range of numbers it contains. The client applies a message only if it starts at or before the next number it expects, so nothing in between is missing.

  • Start: open the stream first, buffering messages. Then fetch the snapshot (say seq 48,200). Drop buffered messages at or below 48,200 and apply the rest.
  • Gap: a message starts at 48,260 but we have 48,240. Something was lost. Fetch a fresh snapshot and start again. Never guess.
  • Reconnect: the browser sends Last-Event-ID. If the server still has those changes in a short ring buffer, it replays them. If not, it sends a reset event, and the client fetches the snapshot.
  • Hidden tab: after about a minute in the background, close the stream. On visibilitychange back to visible, fetch the snapshot and reopen. This frees server connections during a sale when many fans have several tabs open.
Sequence diagram of live availability between Inventory, the Stream edge, the Seat store, and the Canvas. The seat store opens the SSE stream at the edge, then gets the status snapshot from inventory at sequence 48200. Inventory publishes changes 48201 to 48230 to the edge, which sends a diff of 30 seats to the store. The store redraws only the dirty seats on the canvas in one frame. Later a diff arrives starting at 48260 while the store is at 48240: a gap. The store fetches a fresh snapshot to resync.InventoryStream edgeSeat storeCanvasopen SSEGET statussnapshot, 4820048201 to 48230diff, 30 seatsdraw dirty seatsfrom 48260: gapfresh snapshot
The sequence number is what makes this safe. A diff applies only when no change between the store's number and the diff is missing; any gap is repaired from a fresh snapshot, never guessed.

Applying 500 changes without redrawing 80,000 seats

ts
type StatusDiff = { from: number; to: number; changes: [seat: number, status: number][] };

function applyDiff(store: SeatStore, diff: StatusDiff) {
  if (diff.to <= store.seq) return;                       // old or duplicate
  if (diff.from > store.seq + 1) return store.resync();   // gap: refetch snapshot
  // A batch that overlaps the snapshot is safe: changes hold absolute statuses, in order
  for (const [i, status] of diff.changes) {
    const before = store.status[i];
    if (before === status) continue;
    store.status[i] = status;
    store.sectionFree[store.sectionOf[i]] += (status === FREE ? 1 : 0) - (before === FREE ? 1 : 0);
    store.dirty.add(i);
    if (store.mine.has(i) && status !== HELD) store.lostHold(i); // the server shows our holds as HELD too
  }
  store.seq = diff.to;
  store.scheduleDraw();   // one requestAnimationFrame, and only if a dirty seat is visible
  store.notify();         // UI subscribers: counts, cart, list view
}
Applying a batch of diffs to the seat store

The store is plain JavaScript with typed arrays, not React state. React parts (the seats-left badge per section, the list view, the cart) subscribe with useSyncExternalStore and select only what they show, so a diff re-renders a handful of components. The canvas redraws once at the next frame and only if a changed seat is on screen. The SVG seat renderer for small venues keeps a map from seat index to element and changes one class attribute per changed seat.

Your own holds need care. The server shows a seat you hold as held, the same as anyone else's. The client knows which ones are yours from its hold state and draws them as selected. If a diff says one of your seats became sold or free, your hold was lost (for example it expired on the server first), and the cart must tell you.

Check your understanding · Exercise 6

A diff arrives with from: 9001, but the store's seq is 8990. What should the client do? Select all that apply.

Selecting a seat: holds, optimistic updates, and the timer

Clicking a free seat is a request to the server: please reserve this for me. The server places a hold, a short-lived reservation with an expiry time, so nobody else can buy the seat while this fan pays. This chapter designs the client side of that conversation. It matches the fare hold in the travel-booking guide; the difference is that here a hold starts on a single click and fails often.

Two naive approaches, both bad

Hold at checkout only. The fan picks four seats locally and the server first hears about them on Continue. During a rush, thousands of fans have the same front-row seats in their local carts. Most learn they lost them after picking and pressing Continue, the most frustrating possible moment.

Wait for the server on every click. The seat stays unselected until the hold succeeds. During an on-sale, the hold API takes 300 ms to 2 seconds. The fan sees nothing happen, clicks again, and the second click deselects the seat or sends a duplicate request.

Decision When and how do we place holds?

Pessimistic hold per click

Click shows a spinner on the seat. It turns selected only after the server says yes.

Pros

  • The screen never shows something false.
  • Simple state.

Cons

  • Feels broken during slow periods.
  • Fans double click and cause duplicate or opposite requests.

Optimistic hold per click with rollback

Click shows the seat as selected right away, with a subtle pending ring.

The hold request runs. On success the ring goes away. On a conflict the seat rolls back to taken with a message.

Pros

  • Instant feedback.
  • Conflicts surface within a second, while the fan is still looking at that area.

Cons

  • We must handle rollback, rapid toggles, and retries carefully.
  • A failed seat briefly looked like it was yours.

Hold all seats at once on Continue

Local selection, then one request that holds all seats or none.

Pros

  • One request. All-or-nothing keeps groups together.

Cons

  • Late failure, as above.
  • No timer while choosing, so no fairness signal to the fan.

What we pick: Optimistic hold per click, with all of a fan's seats inside one cart hold that has one expiry. The pending ring is honest: the seat looks chosen but not yet confirmed. The optimistic-updates lesson covers the general pattern; the seat-map specifics are rollback that explains what happened, rapid toggles, and idempotent retries.

Sequence diagram of selecting seats, between the Fan, the Map and store, the Hold controller, and the Hold API. The fan taps seat F7; the controller marks it pending, and the map draws a ring at once. The controller posts a hold with an idempotency key. The API answers 200 with the hold and its expiry; the map shows F7 held and the timer starts. Then the fan taps F9. The controller posts a hold, and the API answers 409: someone else took it. The controller rolls F9 back to taken and announces it.FanMap + storeHold ctrlHold APItap F7: toggleSeatF7 pending, ringPOST hold, key k1200, expiresAtF7 held, timer ontap F9POST hold, key k2409 takenroll back, announce
The screen reacts before the network does, and the failure path is a normal branch, not an error page. The 409 body carries the seat's new status, so the map is correct the moment the fan learns why.

Each selected seat is a small state machine

Write the life of one seat in this fan's cart as a state machine, as the state-machines lesson recommends. It removes the bugs that come from flags like isPending and isSelected disagreeing.

State machine for one seat in the fan's cart. Free goes to pending on a click. Pending goes to held when the server answers 200, or to taken when it answers 409. Held goes to expired when the timer reaches zero, or to releasing when the fan clicks again. Releasing goes back to free when the server confirms.freependingheldreleasingtakenexpiredclick200409timer 0click again200expired offers: try to get these seats again
Every seat is in exactly one state, so flags can never disagree. A click while pending only records the new intent; the controller acts on it when the server answers.
  • Rapid toggles. A click on a pending seat records the new intent (release) instead of sending a second request at once. When the hold answers, the controller compares the result with the latest intent and sends a release if needed. Aborting the fetch would not help: the server may already have placed the hold.
  • Idempotency. Each hold request carries an Idempotency-Key header created once per user action. If the network drops and we retry, the server returns the first result instead of creating a second hold.
  • The seat limit. The client refuses the ninth seat with a clear message, and the server enforces the same limit, since client checks are easy to bypass.
  • Rollback that teaches. On a 409 Conflict, the seat turns taken, a polite live region (an element whose text changes screen readers announce) says Seat 7 in row F was just taken, and the map highlights the nearest free seats in the same price level. Focus stays where it was.
ts
async function toggleSeat(seat: number) {
  const entry = cart.get(seat);
  if (entry?.state === "pending") { entry.wantsRelease = !entry.wantsRelease; return render(); }
  if (entry?.state === "held") return release(seat);
  if (cart.size >= maxSeats) return announce(`You can choose up to ${maxSeats} seats.`);
  cart.set(seat, { state: "pending", wantsRelease: false });
  render();
  const res = await holdApi.add({ holdId: cart.holdId, seat, key: crypto.randomUUID() });
  const now = cart.get(seat)!;
  if (res.ok) {
    cart.holdId = res.holdId; cart.expiresAt = res.expiresAt;
    now.state = "held";
    if (now.wantsRelease) return release(seat);
  } else {
    cart.delete(seat);
    if (res.status === 409) { store.markTaken(seat); announce(seatTakenMessage(seat)); suggestNearby(seat); }
    else announce("We could not reserve that seat. Please try again.");
  }
  render();
}
Optimistic hold with rollback

The countdown, extensions, and expiry

The server returns expiresAt as an absolute time. Phone clocks drift, so also read the server's current time from the response and keep the offset; the countdown is expiresAt minus the corrected now. Update the visible mm:ss once a second, but do not announce every tick to screen readers. Announce at 5 minutes, 2 minutes, and 1 minute through a polite live region.

WCAG 2.2 success criterion 2.2.1 Timing Adjustable asks that users can turn off, adjust, or extend a time limit. Its extend option means a warning before the end, at least 20 seconds to extend with a simple action, and at least ten extensions. The W3C's Understanding document names a ticket site that holds seats for a short time as an example of its essential exception, because a longer hold would keep a scarce seat from everyone else.

So a fixed hold can conform, but the kind design still offers more time: at 1 minute left, a dialog asks Need more time? with a button that adds a few minutes. The server decides how many extensions to grant (one, in this guide), and the event can set longer holds for purchases of accessible seating.

When the timer reaches zero, the client does not just wipe the cart. It moves the cart to expired, shows what was lost, and offers Try to get these seats again, which sends a new hold request for the same seats (they are often still free). If the fan has already pressed Pay when the timer hits zero, the client waits for the payment answer instead: the server checks the hold when it creates the order, and a payment that started in time should finish.

Try it below. Select seats while another buyer grabs some, and click a pending seat again to see the recorded intent. Watch optimistic selection, the rollback on conflict, the countdown, the single extension, and expiry.

Click free seats to select them, up to 4. They turn pending at once; press Server answers to resolve the holds.

Two rows of 8 seats. Taken: F3, G2. Pending: none. Releasing after the answer: none. Held by you: none. Timer: no hold. Extensions left: 1.FGF1F2F3F4F5F6F7F8G1G2G3G4G5G6G7G8hold: noneextensions left: 1your seats: 0 of 4hatched = pending
Click free seats to select them, up to 4. They turn pending at once; press Server answers to resolve the holds.

Check your understanding · Exercise 7

A fan clicks a free seat, then clicks it again 200 ms later before the hold answers. What should happen? Select all that apply.

Best available versus pick your own

Many fans do not want to study a map. They want four seats together under 100 dollars each, now.

Best available answers that: the fan picks a quantity and filters, and the system proposes a block of seats. Pick your own is for fans who care about the exact spot. A good component offers both and lets the fan switch freely.

Why adjacency is the whole problem

A group of four almost never accepts four scattered seats. So the search is for a run: N free seats next to each other in one row, with no aisle or gap in between. Two traps make this harder than it sounds.

  • Seat numbers are not positions. Many theaters number odd seats on one side and even on the other, so seat 7 sits next to 9, not 8. Some rows skip numbers around pillars. Adjacency must come from the geometry (the order of seats along the row, with aisle breaks), never from arithmetic on seat numbers.
  • Orphan seats. Selling seats 2 to 5 in a row where only 1 to 5 are free leaves seat 1 alone. Single seats are hard to sell, so venues prefer blocks that do not strand one seat. Pick-your-own maps often warn or block a choice that leaves a single empty seat between two taken ones.
Finding 4 seats together. Row F is numbered with odd seats: 1 is free, 3 is taken, 5, 7, 9, and 11 are free and chosen as the run of four, then an aisle, then 13 taken and 15 and 17 free. Row G has even seats 2 to 14 with 2 taken. A good pick of 4, 6, 8, and 10 leaves 12 and 14 as a pair. A bad pick of 6, 8, 10, and 12 strands seats 4 and 14 alone.Row F1357911aisle1315174 together: 5 to 11Row G2468101214Row G2468101214good: 12 and 14 stay a pairbad: 4 and 14 left alone
Seat 5 sits next to 7, not 6, because adjacency comes from the physical order of the row. A run stops at the aisle, and a good pick avoids leaving one seat stranded.

Decision Where does the best available search run?

On the client, over the seat store

Scan rows for runs of free seats of length N in the fan's price range, rank them, show the top one, then hold it.

Pros

  • Instant, no extra request.
  • Works offline from the snapshot.

Cons

  • The store is up to a second stale, so the best block is also the most contested one.
  • Ranking rules (which sections are better, what the promoter wants sold first) leak into the bundle.

On the server, find and hold in one call

POST /events/123/best-available with quantity and filters. The server picks the block and holds it in the same step, then returns the seats and the hold.

Pros

  • No race: the seats are already held when the fan sees them.
  • Ranking rules stay private and can change without a deploy.

Cons

  • A round trip, slower during a rush.
  • Needs a way to say no thanks, show me another, without leaking holds.

What we pick: The server finds and holds in one call. The answer zooms the map to the block, shows the seats as selected, and starts the timer. Try another releases that hold and asks again, sending the rejected block so the server does not suggest it twice. The client keeps a simpler local search for two jobs where speed matters more than authority: dimming rows that cannot fit N together when the fan sets a quantity, and suggesting nearby seats after a pick-your-own conflict.

ts
function runsOf(n: number, row: RowSeats, isFree: (i: number) => boolean) {
  const runs: number[][] = [];
  let current: number[] = [];
  for (const seat of row.seats) {               // seats in physical order
    if (seat.aisleBefore || !isFree(seat.index)) {
      if (current.length >= n) runs.push(current);
      current = [];
    }
    if (isFree(seat.index)) current.push(seat.index);
  }
  if (current.length >= n) runs.push(current);
  // Prefer runs that do not leave exactly one free seat at either end
  return runs.map((r) => pickBlock(r, n)).filter(Boolean);
}
Local search: runs of N free seats along each row, for hints only

When nothing fits, say what would. If no four seats together exist in the price range, offer the closest alternatives: four seats together just above the range, or two pairs in adjacent rows. The server can return these alternatives in the same response so the fan never sees a dead end.

Check your understanding · Exercise 8

Which statements about best available are true? Select all that apply.

Surviving the on-sale: waiting rooms, fairness, and bots

Everything so far assumed the servers can answer. On a big on-sale they cannot answer everyone at once. For the Verified Fan presale of Taylor Swift's Eras Tour on November 15, 2022, 3.5 million people had registered and about 1.5 million received codes. Ticketmaster reported that bot attacks and fans without codes drove 3.5 billion total system requests, four times its previous peak, and that it slowed and postponed some sales to keep its systems up.

Even so, more than 2 million tickets sold that day, a single-day record for an artist. The frontend cannot fix capacity, but it decides how load arrives and how people feel while they wait.

Decision How do fans enter a sale that is far over capacity?

Let everyone in and scale up

The seat map loads for all; the backend autoscales.

Pros

  • No waiting page.

Cons

  • Inventory is the bottleneck, not servers: 80,000 seats cannot absorb 500,000 buyers.
  • Hold endpoints melt, everyone gets errors, and fast bots win.

First come, first served queue

A waiting room admits fans at a fixed rate, in arrival order.

Pros

  • Steady, known load on the seat map and hold APIs.
  • Easy to explain.

Cons

  • Rewards whoever clicks at exactly 10:00:00, which favors bots and fast networks.
  • People refresh obsessively, adding load.

Randomized early arrivals, then first come

Everyone who arrives in the window before the sale (say 15 minutes) gets a random place. Later arrivals join the back in order.

Admission is a signed, short-lived access token.

Pros

  • No reason to race the clock or refresh.
  • Fair to people on slow connections.

Cons

  • Fans can feel cheated by a high number despite arriving early, so explain the rule on the page.

What we pick: A waiting room with randomized early arrivals. Queue-it calls this a pre-queue and randomizes everyone in it when the sale starts, and Ticketmaster tells fans that the time they join its waiting room before the sale does not set their place in line. The seat map and hold APIs only accept requests that carry a valid admission token, so the queue cannot be skipped by calling the API directly.

Sequence diagram of the waiting room between the Fan, the Queue page, the Queue service, and the Seat map. The fan opens the page ten minutes early. The queue page joins the queue after a bot challenge and gets a signed place cookie. It polls about every 25 seconds with jitter. At the sale start the service assigns a random place, 4,210. While waiting, the page prefetches the seat map files. When it is the fan's turn, the service returns an admission token, the page enters the seat map with it, and the fan sees the map.FanQueue pageQueue serviceSeat mapopen at 9:50join, challenge okplace cookiepoll, 25 s + jitter10:00: place 4,210prefetch map filesyour turn: tokenenter with tokenmap shown
The queue page is static and talks to the origin only through a tiny status call. The place lives in a signed cookie, so a refresh does not lose it, and the seat map files are already cached when the fan gets in.

The client side of a queue

  • The waiting page is tiny and static. HTML, CSS, and a little script from the CDN, with nothing that calls the origin except a small status request. It must survive the full spike.
  • Make refreshing safe. Keep the place in line in a signed cookie, not in page memory, so an accidental refresh or a crashed tab keeps the place. Big queues such as Ticketmaster's still ask fans not to refresh, since every refresh is load; the page can say both: your place is saved, and this page updates itself.
  • Poll slowly with jitter. Ask for position every 20 to 30 seconds plus a random few seconds, so a million tabs do not ask in the same second. Honor Retry-After on 429 and 503 answers.
  • Show progress honestly. People ahead, an estimated wait range, and a progress bar. An exact minute count that keeps changing feels like a lie.
  • Prefetch while waiting. The venue geometry and the seat map code are immutable files on the CDN. Fetch them in the queue so the map opens instantly on admission. Do not prefetch status: it will be stale.
  • Admission expires. The token is valid for a few minutes. A fan who walked away loses it, so the place goes to the next person.

Bots

Bots buy seats to resell them. In the US, the BOTS Act of 2016 makes it illegal to get around a ticket seller's purchase controls, but enforcement has been rare, so sellers defend in layers. Most layers live on the server: verified-fan registration and presale codes, a challenge at queue entry, rate limits per account, device, and payment card, and limits per order.

The frontend's part is to make those checks smooth for humans. Run the challenge in the queue rather than at the seat click, never put a challenge in front of a hold the fan already made, and keep the order limit in the UI so honest fans do not hit it by surprise.

Check your understanding · Exercise 9

Which waiting room choices reduce load and improve fairness? Select all that apply.

Prices and fees: showing the real number

Ticket prices used to be shown as a face value, with service, facility, and processing fees added at the last step. This is called drip pricing, and regulators now ban it for tickets in many places. In the US, the Federal Trade Commission (FTC) Rule on Unfair or Deceptive Fees (16 CFR Part 464) took effect on May 12, 2025 for live-event tickets and short-term lodging.

In short: the total price, including every mandatory fee the seller knows about and can calculate, must be shown upfront and more prominently than other pricing information. Taxes and other government charges, shipping, and optional extras can be left out of that number, but must be disclosed before the fan is asked to pay. Fees must also be described truthfully, and FTC staff guidance warns against vague labels such as service fee or convenience fee.

Several US states and other countries, including the UK, have their own rules. The legal team owns the details, and the component must make any of these rules easy to follow.

What the component does about it

  • The server computes prices. The event layer sends an all-in price per price level in integer cents, plus a breakdown. The client never adds fees itself, so a fee change is a data change, not a deploy.
  • One number everywhere. The legend, the seat tooltip, the list view, the price filter, and the cart all show the same all-in price. A filter that says up to 100 dollars filters on the all-in price, not the face value.
  • Breakdown on request. A Price details button in the cart shows face value, each fee named by what it pays for (for example, venue facility fee), and taxes. The breakdown may sit behind a button; the total may not.
  • Format with the event's currency. Use Intl.NumberFormat with the event's currency code and the fan's locale. A fan in Germany buying for a London show sees pounds, formatted the German way.
Price display. On the map, a seat tooltip reads Section 112, Row F, Seat 7, 95 dollars all-in, aisle. In the cart, Price details shows face value 78 dollars, a ticket seller fee of 13 dollars 50, a venue facility fee of 3 dollars 50, and the all-in total of 95 dollars, with tax shown before payment.Map tooltip and cart7112, Row F, Seat 7$95 all-inaisle seatPrice detailsFace value$78.00Ticket seller fee$13.50Venue facility fee$3.50All-in$95.00tax shown before payment
The first number the fan sees is already the all-in price. The breakdown explains it but never replaces it, and each fee is named by what it pays for.

Prices that move during the sale

Many big events use dynamic pricing: the price of a price level rises with demand. So prices are live data too, and the stream carries price messages next to status diffs. The rule for the client is simple: the price the fan pays is the price returned by the hold.

If the map showed 85 dollars but the hold comes back at 95, the seat is still selected, the cart shows 95, and a clear notice says the price changed before the hold. The fan can release with one click. Silent changes are the fastest way to lose trust and invite complaints.

Check your understanding · Exercise 10

Under all-in pricing rules like the 2025 FTC fee rule, which designs are right? Select all that apply.

Accessibility: a map is not enough

A canvas full of seats says nothing to a screen reader. An SVG with 80,000 labelled circles says too much: tabbing through it would take hours. And a fan using a wheelchair needs to find wheelchair spaces and companion seats as easily as anyone finds a front-row seat. Accessibility here is a design decision with its own options, not labels added at the end.

Decision How do keyboard and screen reader users choose seats?

Label every seat in the map

Each seat element gets an accessible name and a tab stop.

Pros

  • Nothing extra to build for small venues.

Cons

  • Thousands of tab stops.
  • Impossible with a canvas renderer.
  • No way to ask for four seats together in my budget.

A list view as an equal alternative

A filterable list of available seat blocks: section, row, seats, count together, all-in price, and features.

Each block has a real Select button.

Pros

  • Works with every screen reader and keyboard.
  • Faster than the map for many sighted users too.
  • Answers task questions directly: four together, step-free, under 100 dollars.

Cons

  • Loses the spatial picture unless we describe it (distance to stage, side, level).

A keyboard grid inside the map

The section in view becomes a grid: arrow keys move along a row and between rows, Enter selects.

Only the current section has focusable cells, so the map is one tab stop.

Pros

  • Keyboard users who can see the map get the full picture.
  • Screen reader users can explore a section seat by seat.

Cons

  • Real work to build and test across screen readers.

What we pick: Both the list view and the keyboard grid. The list is the primary non-visual path and is a full alternative, reachable from a toggle next to the map with the same filters and the same cart. The keyboard grid serves people who use the map without a mouse. Labelling every seat in the DOM is only an option for small venues using the SVG renderer.

Keyboard grid for section 112. Rows E, F, and G curve and have 5, 6, and 7 odd-numbered seats. Focus is on seat F7. Pressing Up moves to E5, the seat in the row in front that is nearest in position, not E7. Seats F3 and G9 are taken. A note shows what a screen reader says: Section 112, Row F, Seat 7, 95 dollars all-in, aisle, available. Below, the list view describes the same choice in words: section 112, row F, seats 7 and 9, two together, 95 dollars each, with a Select button.Row E13579Row F1357911Row G135791113Up from F7 landson E5: nearest x,not the same numberSection 112, Row F,Seat 7, $95 all-in,aisle, availableList: 112, Row F, seats 7 and 9, 2 together, $95 each, Select
Up and Down move to the nearest seat by position, because rows differ in length. The list view is the same inventory as sentences, which is the fastest path for many people.

The keyboard grid

Use the grid pattern from the WAI-ARIA Authoring Practices with a roving tabindex, as the keyboard-navigation and aria-patterns lessons describe. With a canvas renderer, render a light DOM layer only for the section in view: a role="grid" with one row per seat row and one gridcell per seat, transparent and positioned over the drawn seats. A large section has a few hundred cells, which the DOM handles easily.

ts
function onGridKey(e: KeyboardEvent, at: SeatRef, section: SectionSeats) {
  const moves: Record<string, () => SeatRef | null> = {
    ArrowLeft: () => section.prevInRow(at),
    ArrowRight: () => section.nextInRow(at),
    ArrowUp: () => section.nearestInRow(at.row - 1, section.x(at)), // same x, row in front
    ArrowDown: () => section.nearestInRow(at.row + 1, section.x(at)),
    Home: () => section.firstInRow(at.row),
    End: () => section.lastInRow(at.row),
  };
  if (e.key === "Enter" || e.key === " ") { e.preventDefault(); return toggleSeat(at.index); }
  const next = moves[e.key]?.();
  if (!next) return;
  e.preventDefault();
  focusSeat(next);          // roving tabindex, and pan the camera if needed
}
Arrow keys follow the physical layout, not seat numbers

Rows in a stadium curve and have different lengths, so up and down move to the seat in the next row closest in x, not the same index. Each cell's accessible name reads like a person would say it: Section 112, Row F, Seat 7, 95 dollars all-in, aisle seat, available. Selected seats use aria-selected. Taken seats stay in the grid as unavailable, so the count of seats per row is honest, but skipping over them with a modifier key saves time.

Screen readers do not all treat a custom grid the same way. Some announce row and column positions, some read only the cell's name, and whether arrow keys reach the page can depend on the screen reader's browse or focus mode. Put everything that matters in each cell's name, and test with NVDA, JAWS, and VoiceOver before trusting the grid as the main path.

  • Announcements go through one polite live region owned by the component: hold confirmed, seat taken by someone else, timer warnings. Never move focus to announce. The screen-readers lesson covers live region timing.
  • Not color alone. Status uses shape as well as color: a taken seat is grey with a cross, a selected seat has a ring and a check. Seat colors need 3 to 1 contrast against the background (WCAG 1.4.11), and price levels have names in the legend, not just hues.
  • Focus is visible on both the DOM grid and the drawn seat, and the camera pans to keep the focused seat on screen.

Accessible seating rules

In the US, the Americans with Disabilities Act (ADA) rules for ticket sales (28 CFR 35.138 for venues run by state and local governments, and 36.302(f) for private ones) shape the component. The Department of Justice guidance has the details; in summary, accessible seats must be sold in the same ways, at the same times, and through the same channels as other seats. Seating maps shown to the public must identify accessible seats with the same text and visual language, and describe their features well enough for a buyer to judge whether they fit.

Accessible seats cannot cost more than other seats in the same section, and venues must generally offer them in every price category. The 2010 ADA Standards also require wheelchair spaces to be spread across the venue (dispersed), so they appear in many sections of the map.

A buyer of a wheelchair space can buy up to three companion seats in the same row next to it, if available. Unsold accessible seats may be released to the general public only once all other seats are sold in the whole venue, in that section, or in that price category, and the venue may choose to keep holding them.

For the component, that means an Accessibility needs filter (wheelchair space, companion seat, aisle seat with a movable armrest, step-free route) in both the map and the list, accessible seats drawn with the same visual language as others plus the standard symbol, a wheelchair space selection that offers its companion seats as one block, and longer default holds when the event allows it. The server enforces who can buy what; the client makes the rules easy to follow.

Check your understanding · Exercise 11

Which designs make a canvas seat map accessible? Select all that apply.

Architecture, data model, and interface

Now put the decisions together. The design has three layers: data (loading, the seat store, live sync), logic (camera, hit testing, holds, best available), and views (map layers, cart, list, keyboard grid). The views never talk to the network directly, and the network code never touches the DOM.

Architecture of the seat map component in three layers. Views on top: the Section layer in SVG, the Seat layer on canvas or SVG, the Cart with the timer, and the List view with the keyboard grid. Logic in the middle: the Camera for zoom, pan, and hit testing, the Hold controller, and the Announcer live region. Data at the bottom: the Venue loader, the Seat store, and Status sync. The camera drives both map layers. The seat store feeds the views. The hold controller updates the seat store optimistically and feeds the cart; the list view calls the hold controller. Outside the component, the CDN feeds the venue loader, the Hold API answers the hold controller, and the SSE stream feeds status sync.Seat map componentSection layerSVGSeat layercanvas or SVGCart, timerReactList, gridReact, a11yCamerazoom, pan, hit testHold ctrlstate machinesAnnouncerlive regionVenue loaderSeat storeStatus syncsubscribeCDNHold APISSE stream
Views never call the network, and network code never touches the DOM. The seat store is the meeting point: loaders and sync write to it, views read slices of it.
PartResponsibilityWhy the boundary is here
Venue loaderFetches geometry (immutable) and the event layer, expands rows into seat positions, builds the seat id map and hit-test grid.Runs once per event. Can move to a worker for huge venues, since it is pure computation.
Seat storeTyped arrays for position, status, price level; section counts; the fan's selection; seq; subscribe and select.One source of truth that is fast to write (diffs) and cheap to read (renderers, list).
Status syncOpens the stream, fetches the snapshot, applies diffs in order, detects gaps, resyncs, closes in hidden tabs.All the ordering logic in one place, testable with fake streams.
Camerax, y, scale, clamping, animated zoom to a section, input handling.Shared by both layers, so they can never disagree.
Section layer (SVG)Outlines, labels, stage, seats-left shading, hover and click on sections.Few elements that need DOM events and crisp text.
Seat layerDraws visible seats with level of detail; a canvas renderer or an SVG renderer behind one interface.Swappable by venue size; the rest of the app does not care which.
Hold controllerPer-seat state machine, optimistic updates, idempotent requests, the cart hold, timer, extension, expiry.Business rules away from pixels; easy to test with a fake API and a fake clock.
Cart, list view, keyboard grid, announcerReact views that subscribe to slices of the store and call the controller.Ordinary accessible UI, re-rendered only when their slice changes.

The next figure follows one event load from first byte to first seat, then one status change and one hold. Notice which arrows go through the CDN: everything static does, and nothing live does except the micro-cached snapshot.

Data flow from origin to browser. In the origin, the Venue service sends geometry to the CDN, Inventory sends the status snapshot to the CDN and changes to the Stream edge, and the Hold API places holds in Inventory. In the browser, the CDN delivers files and the status snapshot to the Seat store, and the Stream edge delivers SSE diffs. The Seat store feeds the map, cart, and list, which draw. A click goes from the map to the Hold controller, which updates the Seat store at once and posts the hold straight to the Hold API.OriginVenue svcInventoryHold APIEdgeCDNStream edgeBrowserMap, cart, listSeat storeHold ctrlgeometrysnapshotchangesholdfiles, statusSSE diffsdrawupdatePOST holdclick
Everything static rides the CDN and the live stream rides the edge; only writes go straight to the origin. That is why one on-sale can serve hundreds of thousands of fans without each of them hitting inventory.
  1. The page shell and seat map code come from the CDN. The geometry URL is versioned, so a repeat visitor often has it in the browser cache already.
  2. The event layer (prices, what is on sale) comes from the CDN with a short max-age of a minute or so, purged when the promoter changes prices.
  3. The stream opens, then the snapshot loads (micro-cached for a second at the CDN). The store applies the snapshot, then buffered diffs.
  4. The section layer paints; at the overview no seats are drawn yet, so the first useful map is a few hundred SVG shapes, not 80,000 seats.
  5. A click goes to the hold controller, which updates the store optimistically, calls the hold API at the origin (never cached), and reconciles.

Think first

Should the seat drawing run in a Web Worker with OffscreenCanvas?

Where client data lives

Here is where each piece of client data lives, and why. Getting this table right is most of the data model answer.

DataLives inLifetime
Venue geometryBrowser HTTP cache and CDN, then typed arrays in the seat store.Months; versioned URL.
Event layer (price levels, on-sale seats, limit)Seat store, refreshed by price stream messages.Minutes.
Seat status and seqSeat store typed array, updated by diffs.Seconds.
Selection and holds (hold id, seats, states, expiresAt)Hold controller, mirrored to sessionStorage so a reload can resume the hold.Up to the hold expiry.
Camera, hovered seat, open sectionCamera store and component state; the open section also goes in the URL.This view only.
Filters (quantity, price range, accessibility needs)URL query string, so a shared link reproduces the view.This visit.
Admission token from the queueAn HttpOnly cookie set by the server.Minutes.

The component API

The seat map is a component other teams embed: the event page, the resale flow, and the box office tool. It should know nothing about checkout, auth, or the queue. It takes data sources and reports intent; the host decides what to do.

tsx
type SeatMapProps = {
  venue: VenueGeometry | (() => Promise<VenueGeometry>);
  event: EventLayer;
  availability: AvailabilitySource;      // snapshot() and subscribe(onDiff), so tests can fake it
  holds: HoldApi;                         // add, remove, extend; returns hold state and prices
  maxSeats: number;
  selected?: readonly string[];          // controlled mode, seat ids such as "112-F-7"
  onSelectionChange?: (seats: SelectedSeat[], hold: HoldInfo | null) => void;
  onHoldExpired?: (lost: SelectedSeat[]) => void;
  onContinue: (hold: HoldInfo) => void;
  filters?: { quantity?: number; maxPriceCents?: number; needs?: AccessNeed[] };
  renderer?: "auto" | "svg" | "canvas";  // auto picks by seat count
  renderSeatTooltip?: (seat: SeatInfo) => React.ReactNode;
  initialView?: { sectionId?: string };
  labels?: Partial<SeatMapStrings>;       // all text, for translation
};
Props of the seat map component

availability and holds are interfaces, not URLs. The host passes real implementations; tests and the design system's docs pass fakes that simulate an on-sale. renderer defaults to auto, which picks SVG under about 5,000 seats and canvas above. The tooltip is a render prop because the resale team shows the seller's notes there. The component-api-design ideas behind this, controlled and uncontrolled modes and render props, are the same as in any design system component.

The network API

CallPurposeCaching
GET /venues/412/v17/geometry.jsonVenue shapes and rows.public, max-age=31536000, immutable
GET /events/123/layerPrice levels, seat price levels, limit.public, max-age=60 and purge on change
GET /events/123/statusStatus snapshot with seq.public, s-maxage=1
GET /events/123/stream (SSE)status, price, and reset events.Not cached
POST /events/123/holds and PATCH /holds/:idCreate a hold or add and remove seats. Idempotency-Key header.Not cached
POST /holds/:id/extendAsk for more time.Not cached
POST /events/123/best-availableFind and hold N seats.Not cached
json
// 200 OK
{ "holdId": "h_9f2c", "seats": [{ "id": "112-F-7", "allInCents": 9500 }],
  "expiresAt": "2026-03-14T10:12:00Z", "serverNow": "2026-03-14T10:02:00Z",
  "extensionsLeft": 1, "priceChanged": [{ "id": "112-F-7", "shownCents": 8500 }] }

// 409 Conflict
{ "error": "seat_unavailable", "seats": ["112-F-7"],
  "status": { "seq": 48311, "changes": [[20417, 2]] } }
A hold response, and a conflict

The conflict response carries the latest status of the contested seats. The client applies it at once instead of waiting for the stream, so the seat turns taken in the same moment the fan learns why.

Check your understanding · Exercise 12

Which API decisions help the component? Select all that apply.

Performance budgets, testing, and observability

A seat map fails in public: on the busiest day, on the cheapest phones, in front of the most people. So budgets, tests, and monitoring are part of the design, not chores after it.

MetricBudgetHow the design meets it
Largest Contentful Paint2.5 s or less on 4G at the 75th percentileThe event title and any hero image load first; the map code and seats are not on their critical path.
Map ready (custom mark)About 2.5 s on 4G at the 75th percentileLCP ignores SVG shapes and canvas, so a performance.mark when the overview is drawn tracks the map itself. Section shapes come from cached geometry.
Interaction to Next Paint200 ms or lessA seat click updates the store and repaints one frame before any network work. Long work such as building the grid is done at load or in a worker.
Frame time while panningUnder 16 ms, ideally under 10 ms of scriptOne camera transform, culling, level of detail, color-batched fills, drawing only in requestAnimationFrame.
Seat map JavaScriptAbout 80 to 120 KB compressed, loaded after the event page shellCanvas renderer, list view, and best available panel are separate chunks loaded on demand.
Status freshnessUnder 2 s from server change to pixelSSE diffs batched every 500 ms; only dirty visible seats trigger a redraw.

Testing a component that lives in a race

The bugs that matter appear only under contention: a hold that answers after the fan clicked again, a diff that arrives during a snapshot fetch, a timer that ends while payment is in flight. Unit tests with fixed data never meet them, so the tests must create the race on purpose.

  • Fake data sources. Because availability and holds are interfaces, a simulator can play back a recorded on-sale: thousands of diffs per minute, random conflicts, slow holds, dropped messages, and gaps. Run the hold controller against it with a fake clock to test expiry and extension in milliseconds.
  • Synthetic venues. Generate an 80,000-seat stadium and a 1,500-seat theater in tests. Measure frame times in a performance test on a throttled CPU and fail the build if panning gets slower.
  • Visual tests for each level of detail, so a threshold change does not quietly hide seat numbers.
  • Accessibility tests with automated checks on the list view and the grid, plus manual passes with VoiceOver and NVDA, since live region timing is hard to test automatically.

What to measure in production

  • Hold outcomes: success rate, conflict rate, and time to answer, by section and minute. A conflict rate of 60 percent in one section tells product the map is too stale there or demand is huge.
  • Stream health: lag between a change's server time and when the client applied it, gap and resync counts, and how often fans fall back to polling.
  • Rendering: long animation frames during pan and zoom, from the Long Animation Frames API where available, tagged with venue size and renderer.
  • Funnel: time from admission to first held seat, expiry rate, extension use, and drop-off after a conflict. These are the numbers the business watches on sale day.

Check your understanding · Exercise 13

On sale day, the hold conflict rate jumps from 15 to 70 percent in sections 101 to 110 only. What might it point to? Select all that apply.

Running it in the interview

A seat map interview usually runs 45 to 60 minutes. The interviewer wants to see you find the hard parts quickly, make choices with numbers, and stay calm when they add a twist. Here is a plan that fits.

MinutesWhat to doWhat to draw
0 to 6Ask the scope questions: venue size, on-sale scale, hold length, pick-your-own or best available, devices, accessibility.A list of requirements with numbers.
6 to 12Name the hard parts. Sketch the screen.The wireframe: map, toolbar, legend, cart.
12 to 22Data: the three layers and their caching. Rendering: options, verdict, level of detail.The layers with cache lifetimes, the level-of-detail table.
22 to 32Live status with snapshot and diffs. Holds with optimistic updates and the seat state machine.The hold sequence with the conflict path, the state machine.
32 to 42Architecture and component API.Boxes for loader, store, sync, camera, layers, controller, views.
42 to 55Deep dives the interviewer picks: on-sale queue, accessibility, mobile, pricing.Whatever they ask for.
55 to 60Summarize the trade-offs and what you would measure.Nothing new.

Say the numbers out loud. 80,000 seats, 16 ms per frame, 2 bits per seat, 20 KB snapshot, 10-minute holds, 24 px targets. Numbers show you reason from constraints rather than from habit.

Follow-up questions to expect

Think first

Browsers are fast now, so why not just use SVG everywhere?

Think first

Two fans click the same seat in the same millisecond. What happens?

Think first

The stream server falls over during the on-sale. What does the fan see?

Think first

How would a blind fan buy two seats together near the stage?

Check your understanding · Exercise 14

You have 10 minutes left and have not discussed accessibility. What is the strongest move? Select all that apply.

Summary

  • Split seat data by how often it changes: immutable venue geometry cached for a year, an event layer with prices cached for minutes, and a tiny live status snapshot plus a stream of diffs.
  • Draw by level of detail: section shapes at the overview, seats only when they are big enough to see, and only the visible ones. Cost then depends on the screen, not the stadium.
  • SVG suits small venues and section shapes; a canvas seat layer with grid-based hit testing suits big venues. Put the choice behind a renderer prop.
  • Apply status diffs in sequence order, repair gaps with a fresh snapshot, and redraw only dirty visible seats once per frame.
  • A click places a server hold. Show it optimistically with a pending ring, roll back on conflict with a clear message and nearby alternatives, and send idempotent requests.
  • The hold timer uses server time, warns before expiry, offers an extension, and handles expiry mid-checkout without wiping the fan's work.
  • Find and hold best-available seats in one server call. Adjacency comes from physical seat order and aisles, never from seat numbers.
  • A randomized waiting room with signed admission tokens protects the seat map; the queue page is static, polls slowly with jitter, and keeps the fan's place across refreshes.
  • Show the all-in price everywhere, computed by the server, with a breakdown on request.
  • Accessibility needs its own design: a list view as an equal path, a keyboard grid for the section in view, announcements through one live region, and accessible seating that follows the ADA rules.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next