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.
| Question | Answer we assume | What 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.
- 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.
- 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.
- 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.
- 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.
- 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.
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?
Mostly the same ideas at a much smaller size. A wide-body plane has about 300 to 450 seats, so one DOM button per seat is fine and the rendering chapter mostly disappears. Holds, live availability, and accessibility still apply.
The differences are in the rules: airline seats have fees, exit-row conditions, and one seat per passenger, and they are picked after booking, not during a rush. Say this in the interview: it shows you know which parts of the design are driven by scale.
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?
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.
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.
// 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 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?
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 screen | What we draw | What a click does |
|---|---|---|
| Under 2 px | Section 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 px | Seats as tiny squares, colored by status, no outlines or numbers. | Zoom to the section around the point. |
| 8 to 20 px | Seats as circles with status colors and a ring on selected seats. | Select the nearest seat (with a generous hit radius on touch). |
| Over 20 px | Plus seat numbers, row labels, and icons such as the wheelchair symbol. | Select the seat. |
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.
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();
}
}
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.
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.
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
wheelevents withctrlKeyset to true in current browsers (Safari also fires its owngesturechangeevents, so ignore one of the two). Listen with{ passive: false }and callpreventDefault(), 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: noneon the map only, so the rest of the page still scrolls normally. - Keyboard: when the map has focus,
+and-zoom, arrow keys pan, and0resets. 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-motionis 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.
pointermove or tap then costs a few dozen distance checks, however big the venue is.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
}
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?
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.
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
devicePixelRatiocap 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)(withviewport-fit=coverin 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?
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
seq48,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 aresetevent, and the client fetches the snapshot. - Hidden tab: after about a minute in the background, close the stream. On
visibilitychangeback to visible, fetch the snapshot and reopen. This frees server connections during a sale when many fans have several tabs open.
Applying 500 changes without redrawing 80,000 seats
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
}
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?
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.
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.
- Rapid toggles. A click on a
pendingseat 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 thefetchwould not help: the server may already have placed the hold. - Idempotency. Each hold request carries an
Idempotency-Keyheader 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.
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();
}
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.
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.
Decision Where does the best available search run?
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.
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);
}
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?
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.
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-Afteron429and503answers. - 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.NumberFormatwith 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.
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?
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.
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.
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
}
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.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Venue loader | Fetches 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 store | Typed 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 sync | Opens 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. |
| Camera | x, 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 layer | Draws 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 controller | Per-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, announcer | React 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.
- 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.
- The event layer (prices, what is on sale) comes from the CDN with a short
max-ageof a minute or so, purged when the promoter changes prices. - The stream opens, then the snapshot loads (micro-cached for a second at the CDN). The store applies the snapshot, then buffered diffs.
- 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.
- 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?
Only if profiling shows the main thread is busy enough to drop frames. With level of detail and culling, drawing a few thousand seats takes a few milliseconds, so the main thread is fine for most venues.
Expanding row data into positions and building the grid for 80,000 seats is a better first candidate for a worker: it is pure computation that happens once, and the typed arrays can be transferred back without copying.
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.
| Data | Lives in | Lifetime |
|---|---|---|
| Venue geometry | Browser 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 seq | Seat 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 section | Camera 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 queue | An 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.
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
};
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
| Call | Purpose | Caching |
|---|---|---|
GET /venues/412/v17/geometry.json | Venue shapes and rows. | public, max-age=31536000, immutable |
GET /events/123/layer | Price levels, seat price levels, limit. | public, max-age=60 and purge on change |
GET /events/123/status | Status 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/:id | Create a hold or add and remove seats. Idempotency-Key header. | Not cached |
POST /holds/:id/extend | Ask for more time. | Not cached |
POST /events/123/best-available | Find and hold N seats. | Not cached |
// 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]] } }
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.
| Metric | Budget | How the design meets it |
|---|---|---|
| Largest Contentful Paint | 2.5 s or less on 4G at the 75th percentile | The 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 percentile | LCP 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 Paint | 200 ms or less | A 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 panning | Under 16 ms, ideally under 10 ms of script | One camera transform, culling, level of detail, color-batched fills, drawing only in requestAnimationFrame. |
| Seat map JavaScript | About 80 to 120 KB compressed, loaded after the event page shell | Canvas renderer, list view, and best available panel are separate chunks loaded on demand. |
| Status freshness | Under 2 s from server change to pixel | SSE 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
availabilityandholdsare 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.
| Minutes | What to do | What to draw |
|---|---|---|
| 0 to 6 | Ask 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 12 | Name the hard parts. Sketch the screen. | The wireframe: map, toolbar, legend, cart. |
| 12 to 22 | Data: the three layers and their caching. Rendering: options, verdict, level of detail. | The layers with cache lifetimes, the level-of-detail table. |
| 22 to 32 | Live 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 42 | Architecture and component API. | Boxes for loader, store, sync, camera, layers, controller, views. |
| 42 to 55 | Deep dives the interviewer picks: on-sale queue, accessibility, mobile, pricing. | Whatever they ask for. |
| 55 to 60 | Summarize 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?
For a theater, I would. For a stadium, the overview would hold tens of thousands of DOM nodes that cost memory and repaint time on every zoom, and on a mid-range phone that shows as dropped frames. Level of detail fixes most of it on its own: seats are only drawn when zoomed in, so even SVG only renders a few thousand. Canvas for the seat layer is the extra margin for the biggest venues, behind a renderer prop so both share the component.
Think first
Two fans click the same seat in the same millisecond. What happens?
Both see it selected right away with a pending ring. The server serializes the two holds; one wins. The other gets a 409 with the seat's new status. That client rolls the seat back to taken, announces it politely, and highlights the nearest free seats in the same price level. The loser never reaches checkout with a seat they do not own, because the server is the only judge.
Think first
The stream server falls over during the on-sale. What does the fan see?
The map keeps working from the last known status, with a small notice that availability may be delayed. Status sync falls back to polling for changes with backoff. Holds still work because they are separate HTTP calls, and conflicts carry fresh status in the 409 body, so the map corrects itself where the fan is actually clicking.
Think first
How would a blind fan buy two seats together near the stage?
They switch to the list view, set the quantity to 2, sort by distance to stage, and read blocks like Section 112, Row F, seats 7 and 9, two together, 95 dollars each all-in, aisle. Select places the hold; the live region confirms it and the timer warnings come through the same region. If they want to explore the section, the keyboard grid lets them move seat by seat with arrow keys.
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.
References
- Using server-sent events MDN: the
EventSourceAPI, event ids, and reconnection. - Optimizing canvas MDN: batching draw calls, layered canvases, and avoiding state changes.
- Understanding SC 2.2.1 Timing Adjustable W3C: the options for time limits, the extend option (20 seconds, ten extensions), and a ticket hold as an essential exception.
- Understanding SC 2.5.8 Target Size (Minimum) W3C: the 24 by 24 CSS pixel minimum and its spacing exception.
- Understanding SC 2.5.7 Dragging Movements W3C: why pan and pinch need single-pointer alternatives.
- Grid pattern WAI-ARIA Authoring Practices: keyboard interaction and roles for a grid.
- ADA requirements: ticket sales US Department of Justice guidance on selling accessible seats and companion seats.
- The Rule on Unfair or Deceptive Fees: frequently asked questions FTC staff guide: total price display for live-event tickets from May 12, 2025, what may be left out, and naming fees.
- Taylor Swift | The Eras Tour onsale explained Ticketmaster, November 2022: Verified Fan numbers, 3.5 billion system requests, and what slowed the sale.
- What is a pre-queue? Queue-it: randomizing early arrivals when a scheduled sale starts.
- Largest Contentful Paint (LCP) web.dev: the 2.5 second threshold and which elements count, which is why a map needs its own mark.
- Interaction to Next Paint (INP) web.dev: the responsiveness metric a seat click is judged by.
Concepts behind it
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- Server-sent eventsOne HTTP response that never finishes: the server keeps writing small text events to it, and the browser's
EventSourceparses them, reconnects on its own, and resumes where it left off. The simplest push channel when data flows from server to client. - Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.