cf.completefrontend
My progress
SYSTEM DESIGN · #92

Design Airbnb

A travel booking web app: search stays on a list and a map that move together, pick dates and guests, read a listing, and book it. The hard parts are keeping the list, the map, and the URL in sync while the user pans, drawing hundreds of price markers fast, an accessible date range picker that never shifts a day, and a price that stays honest from the search card to the payment.

The question

Design the web app for Airbnb. A guest types a place, picks check-in and check-out dates and the number of guests, and sees matching stays two ways at once: a list of cards on one side and a map with a price marker for each stay on the other. Moving the map updates the list. Opening a stay shows its photos, reviews, amenities, a calendar of free dates, and a box that prices the trip. From there the guest books and pays.

Many visits are on phones, and many guests arrive from Google straight onto a listing page. Walk me through the search page, the listing page, and booking. I care most about how the list and the map stay in sync, how you keep the map fast, and how the price stays correct all the way to payment.

Real-life examples: Airbnb search, listing, and checkout pages, Vrbo and Booking.com stay search, Hotels.com and Expedia map view, Zillow and Redfin map-and-list search

Requirements: what to ask before drawing anything

Airbnb looks like a search results page with a map bolted on. It is really three products in one web app: a map-driven search where the map is an input, not a picture; a listing page that must rank on Google and load fast from a cold link; and a checkout where money moves and every number must match. Each one has a different hard part, so a strong answer splits its time between them on purpose.

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

QuestionAnswer we assumeWhat it changes
Which screens are in scope?Home search bar, search results (list and map), listing detail, and checkout up to a confirmed booking.Three very different pages: one interactive, one content heavy, one transactional.
Does moving the map change the results?Yes. Panning or zooming searches the visible area, with a "Search as I move the map" toggle that is on by default.The map bounds become part of the query and the URL. Every pan is a possible request, so we need debouncing and cancellation.
How many results can one area have?A city can have 5,000 or more matching stays. The list shows 20 per page; the map shows markers for the current page only, plus clusters when zoomed out.We never draw 5,000 markers at once. The page size and clustering keep the map cheap.
Are prices per night or total?The card shows the total for the chosen dates, with every mandatory fee (and taxes where the market expects them), as consumer rules in the US and elsewhere now require. Without dates it shows a nightly price.The search API must price every result for the chosen dates, and the listing and checkout must show the same total.
Can a guest book instantly?Some listings allow instant book; others send a request the host accepts within 24 hours.Two checkout outcomes and two sets of confirmation screens.
How do people arrive?Many land on a listing page from Google or a shared link; searches start from the home page or a city landing page.Listing pages need server rendering for SEO and a fast LCP. The search page needs a shareable URL.
Which devices?About 60% of visits are on phones. We design for a mid-range Android phone on 4G and scale up to a 1,440 px desktop.On phones the map and the list cannot sit side by side, so the layout changes, not just the column count.
Which locales and currencies?Dozens of languages and currencies, right-to-left languages included. Prices show in the guest's currency.Dates, weeks, and money are formatted per locale. Currency conversion lives on the server.

Functional requirements

  • Search by place, check-in and check-out dates, and guest counts (adults, children, infants, pets), with filters such as price range, room type, and amenities.
  • Show results as a list of cards and as price markers on a map, side by side on desktop. Hovering a card highlights its marker; clicking a marker shows a small card for that stay.
  • Panning or zooming the map searches the visible area, unless the guest turns that off.
  • Every search state lives in the URL, so a search can be shared, bookmarked, and restored with the Back button.
  • A listing page with a photo gallery, title, host, amenities, reviews, a location map, an availability calendar, and a booking box that prices the chosen dates live.
  • Save any stay to a wishlist from the card, the marker card, or the listing page.
  • Book: review the price, pay, and get either a confirmed booking (instant book) or a pending request.

Non-functional requirements, with numbers

Each non-functional requirement gets a number, so it can be checked with field data. The web vitals use Google's thresholds at the 75th percentile of real visits (the value that 75% of visits beat).

  • Fast listing page. LCP (Largest Contentful Paint, when the biggest element on screen is drawn) at or under 2.5 seconds on a mid-range phone on 4G. The LCP element is the first listing photo.
  • Fast search page. The list of cards paints before the map is ready. Results for a new map area show within 500 ms of the user letting go of the map on a good connection.
  • Responsive input. INP (Interaction to Next Paint, the delay from a tap or key press to the next frame) at or under 200 ms, including while the map is being dragged.
  • No jumping. CLS (Cumulative Layout Shift) at or under 0.1. Photos, the map area, and the booking box all reserve their space before content arrives.
  • Correct money. The total shown in checkout always matches what the card is charged. If the price changes between search and payment, the guest is told before paying, never after.
  • Never double book or double charge. A retried or double-clicked payment creates one booking and one charge.
  • Accessible. WCAG 2.2 AA (the Web Content Accessibility Guidelines, level AA). Everything on the map is also reachable from the list, and the date picker works with a keyboard and a screen reader.

Out of scope, said out loud

Name what you leave out, so the interviewer can pull something back in. This guide leaves out: the host side (creating listings, managing calendars, pricing tools), messaging between guest and host, the ranking model that orders results (we consume a ranked list), experiences and services, trip management after booking, and native apps. Payments are in scope only on the client side: we collect the card safely and call the booking API; the payment processor does the rest.

Check your understanding · Exercise 1

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

What makes this hard

Before picking any technique, list the traits of the product that will drive every later choice. Airbnb has six. Each one rules out an approach that works fine on a plain search results page.

  1. The map is an input. On most sites a map shows where the results are. Here, dragging the map changes which results exist. A user who drags for three seconds produces dozens of move events, and each one could be a new search. Requests overlap, arrive out of order, and must never paint stale results over fresh ones.
  2. Two views share one selection. The list and the map show the same 20 stays. Hovering a card must light up its marker within one frame, and clicking a marker must open the right card. Two views that each own a copy of the data drift apart; we need one owner.
  3. The map is heavy. A web map library is often the largest JavaScript on the page, at hundreds of kilobytes, and it draws with WebGL on a canvas. Up to a few hundred price markers sit on top of it. Done naively, this is the page's biggest cost for load time, memory, and frame rate.
  4. Dates are not timestamps. A check-in date is a calendar day in the listing's town, not a moment in time. Code that turns "2026-03-10" into a JavaScript Date can show the guest the 9th, quietly. Night counts, blocked days, and minimum stays all depend on getting this right.
  5. The price moves. The nightly rate, cleaning fee, service fee, taxes, discounts for long stays, and currency rates all change, sometimes between the search and the payment. The number on the card, the number in the booking box, and the number on the charge must agree, or the guest must be told why.
  6. Money and inventory are on the line. Two guests can try to book the same nights at the same second. A guest can double-click Pay, or lose the connection mid-request and retry. The client must make these safe, together with the server.

The wireframe below names the parts of the main screen that the rest of the guide designs: the search bar with place, dates, and guests; the filter row; the list of result cards with paging; the map with price markers and a "Search as I move the map" toggle; and the small card that opens when a marker is clicked.

Wireframe of the desktop search page. At the top, a search bar reads Lisbon, Mar 10 to Mar 14, 2 guests. Below it a row of filter chips: Price, Type, Wifi, Filters. On the left, the results list shows four cards with totals of 631, 540, 702, and 488 euros; the 631 euro card is hovered. Under the cards, page links 1 to 15. On the right, the map shows four price pills; the 631 euro pill is highlighted because its card is hovered. Small dots show other matches, and a checkbox at the top of the map reads search as I move./s/Lisbon/homes?checkin=2026-03-10...Lisbon | Mar 10 to Mar 14 | 2 guestsPriceTypeWifiFilters€631 total€540 total€702 total€488 total1 2 3 ... 15[x] search as I move€631€540€702€488
The list and the map show the same 20 stays. Hovering a card highlights its pill; the dots are matches beyond this page, drawn cheaply and never focusable.

Two more traits shape the trade-offs. First, the search page and the listing page have opposite profiles. Search is an app: highly interactive, personal, not worth indexing for every possible map area.

The listing page is a document: mostly static content, shared and indexed, judged on how fast its first photo shows. The two pages deserve different rendering strategies even inside one codebase.

Second, the site is used before a trip, often on a phone, often on hotel or café Wi-Fi, often across days: a guest searches on the bus, saves three stays, compares them on a laptop that evening, and books two days later. The design must survive slow networks, a refresh in the middle of anything, and prices that changed while the tab sat open.

Think first

A plain search results page (like a shop's search) already solves filters, paging, and URL state. Which of the six traits above does it not have to deal with at all?

Check your understanding · Exercise 2

A teammate proposes: "Let's give the list component and the map component each their own fetch of the results, so they stay independent." What goes wrong? Select all that apply.

Search state: who owns the truth

The search page has many moving pieces: the place, two dates, four guest counts, a dozen filters, the map's position and zoom, the page number, which card is hovered, which marker card is open. Before any fetching, decide where each piece lives. Most bugs on map search pages are two pieces of code that each think they own the same value.

The naive version and how it breaks

The naive build keeps each piece where it is used. The search bar holds the place and dates in its own state, the map library holds its center and zoom inside its own object, and the list holds the page number. A shared store collects whatever the fetch needs. It works in a demo, and then real use breaks it in four ways.

  • Refresh loses the search. The guest refreshes after 10 minutes of tuning filters and lands back on the home page.
  • Sharing is wrong. A friend opens the shared link and sees the default view of the city, not the area and dates that were sent.
  • Back is broken. The guest opens a listing, presses Back, and the map jumps to a different spot, because the map's position was never written anywhere that survives navigation.
  • Values drift. The search bar says 2 guests while the last fetch used 3, because a filter panel updated the store but not the bar's local copy.

Decision What should be the source of truth for the search?

A client store, mirrored into the URL

A global store (for example Zustand or Redux) holds the search. A subscriber writes it to the URL after each change; on load, the URL is read into the store once.

Pros

  • Familiar. Components read one store.
  • Easy to hold values that do not belong in a URL.

Cons

  • Two copies of the same truth: the store and the URL. Back and Forward change the URL first, so the store must listen for popstate and re-read it, and loops between the two are a classic bug.
  • Server rendering has to rebuild the store from the URL anyway.

The URL is the source of truth

Everything that defines the search is parsed from the URL on every render. Changing the search means navigating to a new URL. The result fetch is keyed by the normalized URL.

Pros

  • Refresh, share, bookmark, Back, and Forward all work by default.
  • One copy of the truth. The server can render the same search from the same URL.
  • The results cache key is just the canonical query string.

Cons

  • Parsing must be strict: anyone can type a URL.
  • High-frequency values (the live map position during a drag) cannot go through the URL on every frame.

Each component owns its piece

The search bar, the map, and the list each keep local state and talk through callbacks.

Pros

  • Simplest to start.

Cons

  • All four failures above.
  • Cross-component features such as hover sync need prop drilling or events.

What we pick: The URL is the source of truth for the committed search: place, dates, guests, filters, map bounds and zoom, and page. The map library owns the live camera only while a gesture is in progress, and writes the result into the URL when the gesture settles. Short-lived UI state (hover, the open marker card) lives in a small client store and never touches the URL. This is the url-as-state lesson applied to a page with a map.

Every piece of state and where it lives

StateLives inWhy
Place, dates, guests, filtersURL queryDefines the search. Must survive refresh and sharing.
Map bounds and zoom (settled)URL queryThe visible area is part of the search when "search as I move" is on.
Map camera during a dragMap libraryChanges 60 times a second. Only the settled value matters.
Page numberURL queryBack from a listing must return to page 3, not page 1.
Results for a queryServer cache keyed by canonical URLServer data. Back reuses the cached page instantly.
Hovered stay id, open marker cardSmall client storeShared by list and map, but meaningless in a shared link.
Wishlist membershipServer cache, updated optimisticallyServer data that the user can change.
Draft dates in an open pickerPicker component stateNot committed until the guest presses Apply or closes the picker.

The shape of the URL

The place goes in the path, because it is the most important, most human part and it helps city landing pages rank. Everything else goes in the query string with short, stable names. Dates are plain YYYY-MM-DD strings (the next chapters explain why never timestamps). Bounds are rounded to 4 decimal places, about 11 meters, so two drags that end a few pixels apart share a cache key and the URL stays readable.

The search URL in five parts. The path /s/Lisbon holds the place and helps SEO. Check-in and check-out are day strings. Adults equals 2 holds the guests. The bounds ne and sw with zoom hold the map area, rounded to 4 decimal places, and are written with replaceState. Page equals 2 is the page. All parts except the map area are written with pushState. Below, parseSearch reads the URL strictly with safe defaults, and the canonical key sorts the keys and drops defaults.one URL, five parts/s/Lisbonplace, SEOpushcheckin, outdays, not timespushadults=2guestspushne, sw, zoommap area, 4 dpreplacepage=2pagepushparseSearchstrict, safe defaultscanonical keysorted, defaults dropped
Map moves use replaceState, so Back undoes a decision rather than a nudge. The canonical key makes two equal searches one cache entry.
ts
type Search = {
  place: string;
  checkin?: string; // "YYYY-MM-DD", a calendar day, not a moment
  checkout?: string;
  adults: number; children: number; infants: number; pets: number;
  bounds?: { ne: [number, number]; sw: [number, number] };
  zoom?: number;
  priceMax?: number;
  page: number;
};
const DAY = /^\d{4}-\d{2}-\d{2}$/;
const int = (v: string | null, min: number, max: number, dflt: number) => {
  const n = Number(v);
  return Number.isInteger(n) && n >= min && n <= max ? n : dflt;
};

export function parseSearch(place: string, q: URLSearchParams): Search {
  let checkin = q.get("checkin") ?? undefined;
  let checkout = q.get("checkout") ?? undefined;
  if (!checkin || !checkout || !DAY.test(checkin) || !DAY.test(checkout) || checkout <= checkin) {
    checkin = checkout = undefined; // a broken pair becomes "no dates", never half a pair
  }
  return {
    place, checkin, checkout,
    adults: int(q.get("adults"), 1, 16, 1), children: int(q.get("children"), 0, 15, 0),
    infants: int(q.get("infants"), 0, 5, 0), pets: int(q.get("pets"), 0, 5, 0),
    page: int(q.get("page"), 1, 15, 1),
    // bounds, zoom, and filters are parsed the same way
  };
}
Parse the URL strictly into a typed search; anything invalid falls back to a safe default instead of crashing.

Comparing checkout <= checkin as strings works because YYYY-MM-DD sorts the same way alphabetically and by date. That is one reason to keep dates as these strings everywhere on the client.

Push or replace history?

Every committed change writes the URL, but not every change deserves a Back step. A guest who pans the map 15 times and then presses Back expects to leave the search, not to rewind 15 small pans. So the rule is: deliberate changes (new place, new dates, a filter, a page) use history.pushState through the router; map moves use history.replaceState. Back then undoes a decision, not a nudge.

The canonical form of the query matters for caching. Sort the keys, drop values equal to the default (adults=1, page=1), and round the bounds. Then ?adults=2&checkin=... and ?checkin=...&adults=2 are one cache entry, and the Back button finds results already in memory.

Check your understanding · Exercise 3

Which of these belong in the search page URL? Select all that apply.

Search as you move: keeping the list and the map in sync

Now the core loop. The guest drags the map. When they stop, the list and the markers should show stays in the new area, fast, and never flash results from an older area. This chapter builds that loop in four steps: when to search, how to cancel, what to show while waiting, and how hover connects the two views.

Map libraries fire a move event on every frame of a drag or zoom animation, and a moveend (Mapbox GL, MapLibre, Leaflet) or idle (Google Maps) event when the camera stops. A 3-second drag at 60 frames per second is about 180 move events. Searching on each would send 180 heavy requests, and the server would rank and price 5,000 stays 180 times for an answer nobody sees.

Decision Which map event should trigger a search?

Every move event, throttled

Search at most once every 300 ms while the map moves.

Pros

  • Results update during the drag, which can feel alive.

Cons

  • About 10 requests for a 3-second drag, most of them for areas the guest only passed through.
  • The list re-renders under the guest's eyes while they are still aiming, which is distracting and costs frames during the gesture.

The settle event, debounced

Listen to moveend or idle. Start a timer of about 300 to 400 ms; if another move starts before it fires, reset it. Fire one search when the map has truly come to rest.

Pros

  • One request per intent. A guest who drags, pauses, and nudges again sends one search, not three.
  • No list churn during the gesture, so the drag stays smooth.

Cons

  • Adds the debounce delay to every search. Keep it short.

A manual "Search this area" button

Moving the map only shows a button. Nothing is fetched until it is pressed.

Pros

  • Zero wasted requests. The guest can explore the map freely.

Cons

  • One more tap for the most common action. Results and map disagree until the tap, which confuses people.

What we pick: Debounce the settle event by about 300 ms when "Search as I move the map" is on, and fall back to the "Search this area" button when the guest turns that toggle off. The debounce-throttle lesson explains why debouncing fits here: we want the final position, not samples along the way.

Two details matter. First, ignore moves the app causes itself: when the page loads and fits the map to the results, or when a marker click pans the map to show its card, that camera change must not trigger a new search. Set a flag before calling fitBounds or panTo and skip the next settle event. Without it, a marker click can replace the very results the guest was looking at.

Second, skip tiny moves: if the new bounds overlap the old ones by more than about 90%, the results would barely change, so do nothing.

Step 2: cancel what is stale

Debouncing reduces requests; it does not remove overlap. The guest pans to the east side of Lisbon, the search starts, then 200 ms later they pan to the west side. If the east request is slow and returns after the west one, a naive setResults paints the east results over the west map. This is the race the request-races lesson covers, and the rule for a search is latest wins: the newest request decides what is shown, not the newest response.

ts
let inFlight: AbortController | null = null;
let latestKey = "";

export async function runSearch(key: string, render: (r: Results) => void) {
  inFlight?.abort();                 // cancel the network request we no longer need
  const ctrl = new AbortController();
  inFlight = ctrl;
  latestKey = key;
  try {
    const res = await fetch(`/api/search?${key}`, { signal: ctrl.signal });
    if (!res.ok) throw new Error(String(res.status));
    const data: Results = await res.json();
    if (key !== latestKey) return;   // belt and braces: never paint an old area
    render(data);
  } catch (err) {
    if (ctrl.signal.aborted) return; // aborted on purpose: not an error
    throw err;
  }
}
One search at a time: a new search aborts the previous one, and a late answer is dropped even if abort came too late.

A server-state library such as TanStack Query gives most of this for free: the query key is the canonical URL, changing the key starts a new query, the old one's signal is aborted if the fetch function passes it on, and only the current key's data is shown. Either way, the rule is the same: the URL decides which results are current, and anything else is dropped.

Sequence diagram of searching as the map moves, between the Guest, the Map, the Search page, and the API. The guest drags for 3 seconds. The map fires moveend for area A. After the 300 ms debounce, the search page sends GET for A. The guest drags again; the map fires moveend for area B. The search page aborts A and sends GET for B. A late reply for A arrives and is dropped because its key is no longer current. The results for B arrive, and the page renders the list and pills for B in one render.GuestMapSearch pageAPIdrag for 3 smoveend: area Adebounce, GET Adrag againmoveend: area Babort A, GET BA late: droppedresults for Blist + pills: B
The debounce turns a drag into one search; the abort and the key check make sure only the current area is ever painted, even when replies come back out of order.

Step 3: what to show while waiting

Do not blank the list on every pan. Keep the previous results on screen, dim them slightly, and show a thin progress bar on the map; when the new results arrive, swap them in one render. TanStack Query's placeholderData: keepPreviousData keeps the old key's data while the new one loads, and its isPlaceholderData flag tells you when to dim. A blank list with skeletons on every pan makes the page feel slower than it is and shifts layout.

Empty areas need care too. If the guest drags over the ocean, the search returns zero stays: say so in the list ("No stays in this area. Try zooming out.") and keep the map where the guest put it. Never auto-zoom the map to the nearest results. The guest moved the map on purpose, and moving it back is the app overruling them.

Try the lab below. It replays two drags with a pause between them. Pick when to search, switch between a steady and a jittery network, and turn on "abort + key check", then scrub the time slider to see what the list shows at each moment.

Searching on every move sends 11 requests. Searching on settle sends 2, and on a jittery network the first area's late reply can still land last and drag the list back. The 300 ms debounce merges the two drags into one search, but a real guest can pause longer, so only the guard makes the result safe in every case.

Search on
Network
Two map drags replayed. At 2400 ms the map is centered on area 60 and the list shows results for area 60. Requests sent so far: 11. Paints that jumped back to an older area so far: 5.map view: area 60list shows: area 600 ms2.4 srequests sent: 11jumped back: 5map: area 60list: area 60
At 2400 ms the map shows area 60 and the list shows area 60. every move, jittery network, stale guard off: 11 requests, 11 paints, 5 that jumped back to an older area. Final results painted at 1900 ms.

Step 4: hover and selection across the two views

Hovering a card should highlight its marker: a darker pill, raised above the others. Clicking a marker should open a small card for that stay on the map, and on desktop it is nice to scroll the list so the matching card is visible. Both views read from the same small store: hoveredId and selectedId. Neither view talks to the other directly.

  • Re-render only what changed. If the map's marker layer re-renders all 20 markers on every hover, a fast mouse across the list costs 20 component updates per card. Subscribe each marker to "am I hovered?" with a selector, so a hover change re-renders exactly two markers: the old one and the new one. With a WebGL marker layer, use feature state (next chapter) instead of rebuilding data.
  • Raise the hovered marker. Overlapping price pills hide each other. The hovered one gets a higher z-index so its price is readable.
  • Do not move the map on hover. Panning to every hovered card makes the map lurch and would also trigger a search. Hover highlights; it never moves the camera.
  • Clear on leave. pointerleave on the list clears hoveredId, or a marker stays dark after the mouse has left.

Check your understanding · Exercise 4

The guest clicks a marker. The app pans the map so the marker's card fits on screen. A moment later the list reloads with different stays and the clicked one disappears. What fixes it? Select all that apply.

The map: library, tiles, markers, and clustering

The map is the most expensive thing on the search page: the biggest script, the most GPU work, and the most memory. This chapter picks a map library, explains tiles, decides how to draw price markers, and handles dense areas with clustering. The goal is a map that stays at 60 frames per second while dragging on a mid-range phone, and that never delays the list.

How a web map draws the ground: tiles

A web map cuts the world into square tiles. At zoom 0 one tile shows the whole world; each zoom level splits every tile into four, so zoom z has 4^z tiles (zoom 14, street level, has about 268 million). The map only fetches the dozen or so tiles that cover the screen, plus a ring around them so a short drag does not show gaps. Tiles come in two kinds.

AspectRaster tilesVector tiles
What arrivesA finished picture: a 256 or 512 px PNG, JPEG, or WebP per tile.Geometry and labels as compact binary data (the Mapbox Vector Tile format, built on Protocol Buffers), drawn by the browser.
Who draws itThe server, once. The browser only places images.The client, with WebGL, on every frame.
Zooming between levelsBlurry until the next level's images load.Sharp at every fractional zoom; smooth rotation and tilt.
Styling and languageBaked in. A dark mode or a new label language needs a new tile set.Styled on the client from a style file. Label language and colors can change without new data.
Client costSmall library, little CPU or GPU.Bigger library (hundreds of KB), GPU work, and more memory.

Decision Which map library?

Google Maps JavaScript API

Loaded from Google's servers with a key. Vector rendering with a map ID; AdvancedMarkerElement for HTML markers; the @googlemaps/markerclusterer library for clusters.

Pros

  • The best known base map, places data, and Street View. Users trust it.
  • No tile hosting on our side.

Cons

  • Billed per map load, which adds up at Airbnb's scale.
  • Less control over styling and over when its scripts load.
  • The script is served by Google and cannot be bundled with our code; old versions are retired on Google's schedule, not ours.

Mapbox GL JS or MapLibre GL JS

A WebGL renderer for vector tiles. Mapbox GL JS (v2 and later) uses Mapbox's own proprietary license and billing; MapLibre GL JS is the BSD-licensed, open-source fork of Mapbox GL JS 1.x with the same programming model.

Pros

  • Full control of style, layers, and data. Symbol and circle layers draw thousands of points on the GPU.
  • With MapLibre, tiles can come from any provider or our own CDN.

Cons

  • The largest bundle of the three. Must be lazy loaded.
  • We own more: tile provider, style, and upgrades.

Leaflet

A small DOM-based library. Raster tiles are img elements; markers are DOM elements; clustering via the Leaflet.markercluster plugin.

Pros

  • Small (about 40 KB of gzipped JavaScript, by its own docs), simple, works everywhere.
  • Every marker is plain HTML, easy to style and make accessible.

Cons

  • DOM markers slow down in the hundreds: each frame of a drag moves every element.
  • No smooth vector zoom without plugins.

What we pick: For a design interview, pick a WebGL vector map (MapLibre GL JS on our own tiles, or Google Maps with vector rendering if the business prefers its base map and accepts the per-load cost). Both keep dragging smooth with hundreds of points. Whichever it is, hide it behind a small MapAdapter interface (set bounds, set markers, on settle, highlight id), so the choice can change without touching the search page. Leaflet is the right answer only if the interviewer says the map shows a handful of points.

Drawing the price markers

Each result shows as a rounded pill with a price, for example €631 (the total for the chosen dates, the same number as its card). The question is whether these pills are DOM elements placed over the map, or shapes drawn inside the map's WebGL canvas.

Decision DOM markers or a WebGL layer?

DOM markers

Each pill is a button absolutely positioned over the map. On every camera change the library computes each pill's screen position and sets a CSS transform.

Pros

  • Any HTML and CSS: the price, a heart, a hover style, a transition.
  • Real buttons: focusable, labeled, clickable, readable by screen readers.

Cons

  • Cost grows with the count. Moving 20 elements per frame is nothing; moving 500 drops frames on a phone.
  • Overlapping pills need manual z-index and collision handling.

A WebGL symbol or circle layer

The points are a GeoJSON source. The map draws them as part of the canvas. Clicks are matched with queryRenderedFeatures; hover uses setFeatureState to change one point's style without re-uploading data.

Pros

  • Thousands of points at full frame rate.
  • Built-in collision detection hides overlapping labels.

Cons

  • Styling is limited to what the style language supports. Rich pills with icons are harder.
  • Nothing in the canvas is in the accessibility tree; we must provide the alternative ourselves.

What we pick: Use both, each for what it is good at. The 20 stays on the current page get DOM price pills: few enough to be cheap, rich enough to show price and hover state, and real buttons. The other matches in the area (up to a few hundred) show as small dots in a WebGL circle layer, clustered when zoomed out. The guest sees where else to look, and the page never moves more than about 20 DOM elements per frame.

Two layers of markers compared. Left, the DOM layer: four price pills, 631, 540, 702, and 488 euros, as real buttons; the 631 euro pill is hovered. They are focusable and styled with CSS, and each frame moves them with a transform. Right, the WebGL layer: a dozen small dots and two clusters of 24 and 9, drawn inside the map canvas; hundreds are cheap, but none of them is in the tab order.DOM pills: this pageWebGL dots: the area€631€540€702€488249rich, focusable buttonscost grows per elementthousands per framenot in the tab order
The page uses both, stacked: about 20 DOM pills for the stays in the list, over a canvas layer of dots and clusters for every other match in the area.

Clustering dense areas

Zoomed out over Paris, 3,000 dots would sit on top of each other. Clustering replaces nearby points with one circle that shows a count; zooming in splits it. The standard client-side tool is supercluster, the open-source library from Mapbox. It works in two phases.

  1. Index once. Load all points. Starting at the most detailed zoom (16 by default) and moving out one level at a time, walk the points: for each point not yet taken, find every neighbor within a radius (40 screen pixels by default) using a spatial index (a KD-tree), merge them into a cluster at their weighted center, and mark them taken. Each level's clusters become the input for the next level out. The result is a tree: every cluster knows its children.
  2. Query per frame. getClusters(bbox, zoom) returns the clusters and single points for the visible box at the current zoom. Because the work was done up front, this takes well under a millisecond for typical views. getClusterExpansionZoom(id) tells you the zoom at which a cluster splits, so clicking a cluster can zoom right to it.

Index tens of thousands of points in a Web Worker so the main thread stays free; for 500 points the main thread is fine. If a whole city has more matches than we want to send, cluster on the server instead: group points into grid cells (by tile or geohash) and send one count per cell. Server clustering scales to any size but each zoom level needs a request.

The lab below builds the same hierarchy for 42 stays, from zoom 16 out to the zoom you pick. Zoom in and the same stays sit farther apart in screen pixels, so clusters split into single price pills; a bigger radius merges more. Turn clustering off to see the pile of overlapping pills it prevents.

Zoom
Radius
A map of 42 stays at zoom 12 with a 40 pixel cluster radius, clustering on. On screen: 6 clusters holding 36 stays and 6 single price pills. 0 groups are off screen.zoom 12: distances are 4x larger on screen than at zoom 101139472€96€130€131€106€280€84DOM pills: 6clusters: 6stays in clusters: 36groups off screen: 0
Zoom 12, radius 40 px: 6 clusters holding 36 stays, 6 single price pills, 0 groups off screen. Zoom in and clusters split, because the same stays are farther apart in screen pixels.

Loading the map without delaying the list

  • Paint the list first. The search page is server rendered with the first 20 cards. The map area reserves its space with a fixed size and a plain background, so nothing shifts when it arrives.
  • Load the map code after the list is interactive. Import the map module with a dynamic import() once the page has hydrated, and add preconnect hints for the tile and style origins so their connections are open by then.
  • On phones, load the map only when it is shown. The phone layout shows the map behind a list sheet (see the layouts chapter); if the guest never reveals it, the library never downloads. Start loading when they begin to drag the sheet down, not when it finishes.
  • Keep one map instance. Going from search to a listing and back must not destroy and rebuild the WebGL context. Keep the map in a layout that survives route changes, or cache the instance and re-attach its container.
  • Handle a lost GPU context. Browsers can drop WebGL contexts under memory pressure. Listen for webglcontextlost, show a "Map unavailable, results still listed" note, and keep the list working.

Check your understanding · Exercise 5

The product team wants every one of the 2,000 matches in Barcelona drawn as a full price pill at all zooms. Which concerns are real? Select all that apply.

The date range picker and the guest picker

Picking dates looks like a small widget. It is where a travel site's quietest and most expensive bugs live: a check-in that shows one day early for guests in the Americas, a night count that is off by one across a daylight saving change, a blocked night that can be booked anyway. This chapter builds a range picker that handles blocked dates and minimum stays, is fully usable from a keyboard, and never shifts a day.

Decision How should the guest pick a date range?

Two native date inputs

Two input type="date" fields, one for check-in, one for check-out, with min and max.

Pros

  • Free accessibility and a native picker on phones.
  • No code to maintain.

Cons

  • Cannot mark individual blocked days or show a minimum stay.
  • No range highlight; two separate pickers for one decision.
  • Looks different on every browser.

A custom range calendar following the ARIA grid pattern

One or two month grids. The first click sets check-in, the second sets check-out, days in between are highlighted, and blocked or too-short choices are disabled with a reason. Keyboard and screen reader behavior follow the WAI-ARIA date picker dialog pattern.

Pros

  • Shows availability, minimum nights, and prices per day if wanted.
  • One consistent experience across devices.

Cons

  • A lot of careful work: keyboard, labels, focus, locales, right-to-left.

A headless library for the hard parts

Use a tested headless range calendar (for example React Aria's RangeCalendar with its isDateUnavailable callback) for keyboard, focus, and labeling, and style it ourselves.

Pros

  • Accessibility and locale handling already tested across screen readers.
  • We focus on the booking rules.

Cons

  • A dependency to keep up to date; some rules (check-out-only days) still need our code.

What we pick: A custom range calendar for the experience, built on a headless library for keyboard, focus, and locale behavior. The booking rules on top (blocked nights, check-out-only days, minimum and maximum stay) are ours, because they are the product.

Nights, not days

Availability is about nights. A stay from March 10 to March 14 uses the nights of the 10th, 11th, 12th, and 13th: four nights, and the 14th is only a check-out day. So if another guest has booked from the 14th, March 14 is blocked as a night but still fine as a check-out day. A calendar that marks the 14th as fully unavailable wrongly blocks the most common booking: the one that ends where the next begins.

  • Before check-in is chosen, a day is selectable if its night is free and at least the minimum stay of free nights follows it.
  • After check-in is chosen, a day is selectable as check-out if every night from check-in up to the night before it is free, and the count is between the minimum and maximum stay. The first blocked night caps the range: everything after it is disabled.
  • Clicking before the current check-in starts a new range from that day, instead of creating an inverted range.
  • Disabled days say why. A tooltip or the accessible name says "Minimum stay is 3 nights" or "Check-out only", not just a gray number.
State machine of the date range picker. From No dates, clicking a free night goes to Check-in set, where only valid check-out days are enabled. In Check-in set, clicking an earlier day restarts with that day as check-in, and Clear goes back to No dates. Clicking a valid check-out day goes to Range chosen, showing 4 nights. In Range chosen, clicking any day starts a new range and goes back to Check-in set. Apply goes from Range chosen to Applied, where the dates are written to the URL.No datesnothing selectedCheck-in setvalid ends enabledRange chosen4 nightsAppliedwritten to URLclick free nightClearearlier day: restartvalid endnew startApply
Every click has a defined meaning in every state, including the confusing ones: a click before the check-in restarts the range instead of making an inverted one.

The date-only problem

JavaScript's Date is a moment in time, not a calendar day. new Date("2026-03-10") parses a date-only string as midnight UTC. A guest in New York (four hours behind UTC in March) who calls getDate() on it gets 9, because in New York that moment is still March 9 at 8 p.m. The guest picks the 10th and the confirmation says the 9th.

Stepping one day by adding 24 * 60 * 60 * 1000 milliseconds to a local date has a second trap: on a daylight saving change, a local day is 23 or 25 hours long.

The fix is to stop using moments for days. Keep every date as a YYYY-MM-DD string on the client, in the URL, and in the API, and do the math (next day, nights between) in UTC, where days are always 24 hours. Temporal.PlainDate is exactly this type: a day with no time and no zone. As the locale-formatting lesson notes, Temporal is still reaching browsers, so use it only where your targets have it (or with a polyfill); until then, a small helper is enough.

ts
export type Day = string; // "YYYY-MM-DD"

const toUtc = (d: Day) => {
  const [y, m, day] = d.split("-").map(Number);
  return Date.UTC(y, m - 1, day);
};
const fromUtc = (ms: number): Day => new Date(ms).toISOString().slice(0, 10);

export const addDays = (d: Day, n: number): Day => fromUtc(toUtc(d) + n * 86_400_000);
export const nightsBetween = (a: Day, b: Day) => Math.round((toUtc(b) - toUtc(a)) / 86_400_000);

// "Today" in the listing's town, not on the guest's laptop.
export function todayIn(timeZone: string): Day {
  const parts = new Intl.DateTimeFormat("en-US", {
    timeZone, year: "numeric", month: "2-digit", day: "2-digit",
  }).formatToParts(new Date());
  const get = (t: string) => parts.find((p) => p.type === t)!.value;
  return `${get("year")}-${get("month")}-${get("day")}`;
}
Calendar-day math without time zones: parse into UTC, step in whole days, format back.

The last helper matters for the first selectable day. A guest in Tokyo at 9 a.m. on March 10 looks at a cabin in Hawaii, where it is still 2 p.m. on March 9. Whether March 9 can be booked for tonight is a question about Hawaii's calendar, so "today" comes from the listing's IANA time zone (for example Pacific/Honolulu), which the API sends with the listing. The server checks the same rule; the client only avoids offering days the server would refuse.

Keyboard, screen readers, and locales

  • One tab stop for the grid. Only the focused day has tabindex="0"; the rest have -1 (roving tabindex). Arrow keys move by day and week, Home and End go to the start and end of the week, Page Up and Page Down change the month, and Shift with Page Up or Page Down changes the year, as in the WAI-ARIA date picker dialog pattern. See the keyboard-navigation and aria-patterns lessons.
  • Each day has a full name. The visible text is 10; the accessible name is "Tuesday, March 10, 2026", plus "selected as check-in", "check-out only", or "unavailable". Selected days use aria-selected, blocked ones aria-disabled so they stay focusable and can explain themselves.
  • Announce the result. After the second click, a polite live region says "March 10 to March 14, 4 nights".
  • Week starts differ. The US starts weeks on Sunday, most of Europe on Monday, and some countries in the Middle East on Saturday. new Intl.Locale(locale).getWeekInfo().firstDay gives the answer, but it reached all major engines only recently (Firefox added it in 2026, and older Chrome versions had a weekInfo property instead), so feature-detect both and keep a small fallback table by region. Month and weekday names come from Intl.DateTimeFormat for the guest's locale.
  • Right-to-left. In Arabic or Hebrew the grid runs right to left, so the left arrow moves to the next day. Mirror the keys with the layout. The locale-formatting lesson covers the rest.

The guest picker

Guests are four steppers: adults, children, infants, pets. Each stepper is a pair of real buttons ("Decrease adults", "Increase adults") around a number, with the minus disabled at the minimum and the plus disabled at the maximum. The panel is a popover; positioning, focus, and closing work as the dropdown-select guide describes.

The rules come from the server, per listing: the maximum guest count, whether pets are allowed, and which counts include whom. The client does not hard-code them. It disables what the listing does not allow and explains why ("This place allows a maximum of 4 guests").

Check your understanding · Exercise 6

A host's calendar has the night of March 14 booked. A guest wants to stay from March 11 to March 14. What should the calendar do? Select all that apply.

The listing page: server rendered, with a live booking box

The listing page is where the guest decides. It is long: a photo gallery, title and host, highlights, a description, amenities, an availability calendar, reviews, a location map, house rules. Beside all that, on desktop, a sticky booking box shows the price for the chosen dates and the Reserve button. Many guests land here first, from Google or a shared link, so its first load matters more than any other page's.

Decision How should the listing page be rendered?

Client-side rendering

Send an empty shell and a bundle; the browser fetches the listing and renders it.

Pros

  • Simple to build and deploy.

Cons

  • Search engines and link previews see little or nothing without running scripts.
  • LCP waits for the bundle, then the API, then the first photo: three round trips in a row on a cold phone.

Server rendering per request

The server fetches the listing and renders full HTML, then the client hydrates. Streaming sends the top of the page first.

Pros

  • Real content in the first response: good for SEO, link previews, and LCP.
  • Personal parts (wishlist heart, price for my dates) can be included.

Cons

  • Every view costs server rendering, even though most of the page is the same for everyone.

Server rendered and cached at the CDN, with personal parts on the client

Render the shared content (photos, title, description, amenities, review summary, first reviews) on the server, and cache the HTML at the CDN per listing, locale, and currency for a few minutes with stale-while-revalidate. The price for the guest's dates, availability, and the wishlist heart load on the client after hydration.

Pros

  • Most visits are a CDN hit: fast first byte everywhere.
  • The parts that change per guest or per minute are always fresh.

Cons

  • The booking box shows a placeholder for a few hundred milliseconds while its quote loads; it must reserve its space.
  • Two rendering paths to reason about.

What we pick: Server render the shared content and cache it at the CDN; load the dated, personal parts on the client. The ssr and choosing-rendering lessons cover the general trade-off; the specific reason here is that most of the page is identical for every guest in a locale, while the small part that differs (price for my dates, my wishlist) is also the part that must never be stale.

Wireframe of the listing page on desktop. At the top, a photo grid: one large photo marked as the LCP element and four small ones. Below on the left, a column of sections: title, host, and highlights; description; amenities, top 10; availability calendar; reviews, map, and rules. On the right, a sticky booking box shows a total of 631 euros, the dates Mar 10 to Mar 14, 2 guests, and a Reserve button. The booking box loads its quote on the client./rooms/4821?checkin=2026-03-10...photo 1 (LCP)2345title, host, highlightsdescriptionamenities, top 10availability calendarreviews, map, rules€631 totalMar 10 | Mar 142 guestsReservequote: client fetch
Everything on the left is server HTML cached at the CDN. The booking box is the client's: its quote, the calendar's availability, and the wishlist heart load after hydration.

Each part of the page, and how it loads

PartHow it loadsNotes
Photo gallery (first 5)Server HTMLFirst photo is the LCP element: fetchpriority="high", never lazy, srcset with sizes. Others are below it in priority.
Title, host, highlights, descriptionServer HTMLIndexed text. Long descriptions are cut with a "Show more" button, but the full text is in the HTML.
AmenitiesServer HTML (top 10)The full list of 50 or more opens in a dialog from data already on the page.
Review summary and first 6 reviewsServer HTMLAverage, count, and category scores are SEO content. More reviews load in a dialog with cursor pagination.
Availability calendarClient fetch after hydrationOne call for the next 12 months, cached for a minute. It changes when anyone books.
Booking box priceClient fetch per dates and guestsA quote from the server, never computed on the client. See the next chapter.
Location mapStatic image, then interactive on demandAn approximate area, not the address, until booking. The map library loads only when the section nears the viewport.
Wishlist heartClient fetchPersonal. The CDN copy of the page must never contain it.

Desktop shows a grid of five: one large photo and four small ones. Phones show one photo at a time in a swipeable row with a "1 / 32" counter. "Show all photos" opens a photo tour grouped by room. The image-optimization lesson covers formats and responsive sizes, and the pinterest guide covers decoding many images; the listing-specific points are short.

  • Only the first photo is urgent. It gets fetchpriority="high" and a preload link in the head if it is discovered late. The four small ones load normally; the photo tour's 30 or more photos load lazily when it opens.
  • The tour is a URL. Opening it adds ?modal=photos with pushState, so Back closes it instead of leaving the listing. The modal-dialog guide covers focus and closing.
  • Swipe without scripts on phones. A horizontal overflow-x: auto row with scroll-snap-type: x mandatory gives native swipe physics. Script only updates the counter, using IntersectionObserver on the photos.
  • Every photo has alt text from the host's caption or the room tag ("Bedroom 1: queen bed"), with a generic fallback.

The booking box

The booking box holds the dates, the guests, a price breakdown, and the Reserve button. On desktop it sticks beside the content with position: sticky; on phones it is a bar fixed at the bottom with the total and a Reserve button, which opens the dates and guests full screen. Its state comes from the URL: arriving from search with ?checkin=2026-03-10&checkout=2026-03-14&adults=2 fills it in. Changing dates here writes the URL with replaceState, so a refresh or a shared link keeps them.

Each change of dates or guests asks the server for a new quote: the nightly lines, fees, taxes, and total. The box keeps showing the previous breakdown, dimmed, while the new one loads, and cancels the older request if the guest changes dates again. If the dates become unavailable in the meantime (someone else booked them), the quote answer says so and the box says "Those dates are no longer available" and reopens the calendar, rather than showing a price that cannot be booked.

text
€120 x 4 nights                 €480.00
Cleaning fee                     €45.00
Service fee                      €74.12
Taxes                            €31.60
-----------------------------------------
Total (EUR)                     €630.72
You won't be charged yet
What the booking box shows: every line comes from the quote; the client only formats it.

Think first

The listing HTML is cached at the CDN for 5 minutes. A logged-in guest who has this stay on a wishlist sees an empty heart for a moment, then a filled one. Why not render the filled heart on the server?

Check your understanding · Exercise 7

Which choices help the listing page's LCP on a phone that lands from Google? Select all that apply.

Price consistency: one number from card to charge

A guest sees €631 total on a search card, €631 in the booking box, and €631 on the payment page, and is charged €631. That sounds like the minimum. It is one of the hardest guarantees on the site, because the price is not a stored number: it is computed from the dates, guests, host's rules, fees, taxes, and currency rates, all of which can change while the tab sits open.

The naive version and how it breaks

The naive client fetches the listing's nightly rate and fees and adds them up itself: nightly * nights + cleaning + serviceFee. It breaks in at least five ways.

  • Nights cost different amounts. Fridays and Saturdays are often higher, and hosts set custom prices for holidays. There is no single nightly rate.
  • Discounts and fees depend on the stay. Weekly and monthly discounts, early-bird or last-minute discounts, and service fees that scale with the subtotal.
  • Taxes depend on the place. City, regional, and national taxes, some per night, some per person, some a percentage, with rules only the server should know.
  • Currency and rounding. Converting each line and then adding gives a different total than adding and then converting, by a cent or two. A cent of difference between the screen and the charge is a support ticket and, in some markets, a legal problem.
  • Time. The card price was computed when the search ran, maybe an hour ago in a background tab. The host has since raised the price, or a discount ended.

Decision Who computes the price, and how do the three screens agree?

The client computes it

The API returns rates and fees; the client adds them up on each screen.

Pros

  • Instant updates when dates change, no round trip.

Cons

  • Duplicates complex, changing rules in two languages. Any drift charges a different amount than shown.

The server prices each screen independently

Search, listing, and checkout each ask the pricing service for a fresh total.

Pros

  • Always fresh, one implementation.

Cons

  • Each screen can show a different number with no explanation. Nothing ties what the guest agreed to with what is charged.

Server quotes with ids and expiry

Every price the guest can act on is a quote: an id, the inputs (listing, dates, guests, currency), line items, a total in integer minor units, and an expiresAt time. Booking sends the quote id. The server re-prices; if the total is unchanged it books, otherwise it refuses with the new quote.

Pros

  • The charge is tied to the exact number the guest saw and agreed to.
  • Changes are detected by the server and shown to the guest before payment, never after.

Cons

  • Quotes must be stored or signed, and expire.
  • The client needs a clear "price changed" step.

What we pick: Server quotes. The client never adds money; it formats what the server sends. Search results carry an indicative total computed at search time; the booking box and checkout carry real quotes with ids. The booking call is accepted only against a valid quote whose total still holds.

What a quote looks like

ts
type Money = { amount: number; currency: string }; // 63072 + "EUR" = €630.72

type Quote = {
  id: string;                 // "q_7Hk2..." stored or signed on the server
  listingId: string;
  checkin: string;            // "2026-03-10"
  checkout: string;           // "2026-03-14"
  guests: { adults: number; children: number; infants: number; pets: number };
  lines: { kind: "nights" | "cleaning" | "service" | "tax" | "discount"; label: string; amount: Money }[];
  total: Money;
  chargeCurrency: string;     // can differ from the display currency
  expiresAt: string;          // ISO timestamp, for example 30 minutes after creation
};

const formatMoney = (m: Money, locale: string) => {
  const nf = new Intl.NumberFormat(locale, { style: "currency", currency: m.currency });
  const digits = nf.resolvedOptions().maximumFractionDigits ?? 2;
  return nf.format(m.amount / 10 ** digits);
};
Money is always integer minor units plus a currency code. The client formats; it never does arithmetic on it.

The formatMoney helper reads how many decimals the currency uses from Intl.NumberFormat itself: 2 for euros, 0 for Japanese yen. Better still, have the API send the minor-unit exponent with the currency, so client and server agree by contract. Never store money as a float: 0.1 + 0.2 is 0.30000000000000004 in JavaScript.

The life of a quote

The timeline below follows one booking attempt. The search card shows an indicative total, and the listing page fetches a quote for the same inputs. Checkout reuses that quote if it is still valid, or fetches a fresh one. If the guest leaves the payment page open past expiresAt, the client fetches a new quote before enabling Pay, and compares totals.

Timeline of one quote. At 10:00 the search card shows an indicative 631 euros. At 10:04 the listing page gets quote q1 for 630.72 euros, valid for 30 minutes. At 10:06 checkout opens and reuses q1. At 10:34 q1 expires while the guest is away. At 10:46 the guest presses Pay; the client fetches quote q2 and compares totals before paying.indicativecard: €63110:00€630.72, 30 minquote q110:04reuses q1checkout10:06coffee breakq1 expired10:34compare totalsPay: q210:46q1 valid: 30 minq2same total: pay silently. new total: show both, ask again.
The guest sees a change only if the total changed. An expired quote with the same total is swapped silently; a different total needs a new tap on Pay.
  • Same total: refresh silently. If the new quote's total equals the old one, swap the id and say nothing. Expiry is our bookkeeping, not the guest's problem.
  • Different total: say so, before payment. Show "The price for these dates changed from €630.72 to €655.10" with the changed lines highlighted, and require a new tap on Pay. Never charge a different amount than the button showed.
  • Dates gone: offer a way out. If the dates are no longer available, explain it and send the guest back to the calendar with the dates kept, so they can shift by a day.
  • Search card versus listing. If the listing's quote differs from the card the guest clicked, the listing simply shows the fresh number. Optionally note "Price updated" if the difference is large; hiding a jump of 20% erodes trust.
  • Charge currency. If the guest browses in US dollars but the charge happens in euros, the checkout says so next to the total. Honest beats surprising: a currency conversion fee from the card issuer is real money for the guest.

Check your understanding · Exercise 8

The guest opens checkout, goes to make coffee, and comes back 40 minutes later. The quote expired after 30 minutes. What should happen when they press Pay? Select all that apply.

Booking: holds, payment, and booking exactly once

The guest presses Reserve. The checkout page shows the trip (dates, guests), the price from the quote, the cancellation policy, a payment form, and for request-to-book listings a message to the host. Then Confirm and pay. Three things must be true no matter what the network or the guest does: the card is collected safely, the nights are not sold twice, and one press creates exactly one booking and one charge.

When to hold the nights

Decision When should the nights be held for this guest?

Hold when checkout opens

Opening checkout reserves the nights for, say, 10 minutes, with a countdown on the page.

Pros

  • The guest who fills in the form will not lose the dates at the last second.

Cons

  • Many people open checkout just to see the final price. Each one blocks real inventory from other guests for 10 minutes.
  • A countdown adds pressure, which feels like a dark pattern for a stay that is rarely contested.

No hold; reserve atomically at submit

Checkout only reads. On Confirm and pay, the server reserves the nights, authorizes the payment, and confirms, as one operation that rolls back if any step fails.

Pros

  • Inventory is never blocked by browsing.
  • Simple client: one call, several possible answers.

Cons

  • Rarely, someone else books the same nights while the guest types their card. The guest learns it at submit and must pick again.

What we pick: No hold on open; an atomic reserve-authorize-confirm on submit, with the server holding the nights only for the seconds the payment step takes. Unlike concert seats, a given night at a given listing is rarely contested by two people in the same minutes, so the rare conflict is cheaper than blocking every browser. The client's job is to handle the "dates just got booked" answer gracefully.

Collecting the card: hosted fields

Our page must never see a card number. Payment processors (Stripe, Adyen, Braintree and others) offer hosted fields: the card inputs are small iframes served from the processor's domain, placed inside our form and styled to match. The guest types into the processor's page, and our scripts only receive a token or a payment method id.

This keeps our site out of most of the PCI DSS (the card industry's security standard) requirements, and means an injected script on our page cannot read the card. The client flow is:

  1. Mount the hosted fields; show their validation errors inline ("Your card number is incomplete").
  2. On submit, ask the processor's script to create a payment method. It returns an id such as pm_...; no card data touches our servers.
  3. Send the booking request with the quote id, the payment method id, and an idempotency key.
  4. If the bank requires strong customer authentication (3-D Secure, common for European cards under PSD2), the server answers requires_action. The processor's script shows the bank's challenge; when it completes, the client calls the booking API again with the same key to finish.

Booking exactly once

Disabling the button after the first press stops double clicks. It does not stop the dangerous case: the request reaches the server, the booking succeeds, and the response is lost (the phone switched from Wi-Fi to mobile data). The client sees a timeout. If it tells the guest "Something went wrong, try again" and they do, a naive server books twice and charges twice.

The fix is an idempotency key: a random id (crypto.randomUUID()) created once per booking attempt and sent as an Idempotency-Key header. The server stores the result of the first request under that key; any repeat with the same key returns the stored result instead of doing the work again. Stripe's API works this way, and an IETF draft proposes the header as a standard.

ts
function attemptKey(quoteId: string) {
  const k = `booking:${quoteId}`;
  let key = sessionStorage.getItem(k);
  if (!key) { key = crypto.randomUUID(); sessionStorage.setItem(k, key); }
  return key;
}

export async function submitBooking(body: BookingRequest) {
  const key = attemptKey(body.quoteId);
  for (let attempt = 0; attempt < 3; attempt++) {
    if (attempt > 0) {
      const wait = 1000 * 2 ** (attempt - 1) * (0.5 + Math.random()); // backoff with jitter
      await new Promise((r) => setTimeout(r, wait));
    }
    try {
      const res = await fetch("/api/bookings", {
        method: "POST",
        headers: { "Content-Type": "application/json", "Idempotency-Key": key },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(20_000),
      });
      if (res.status >= 500) continue;            // outcome unknown: retry with the same key
      return (await res.json()) as BookingResult; // confirmed, pending_host, requires_action, price_changed...
    } catch {
      // network error or timeout: also unknown, and safe to retry with the same key
    }
  }
  return { status: "unknown", key } as const; // show "Checking your booking..." and poll by key
}
One key per attempt, kept across retries and even a reload; a timeout means "unknown", not "failed".

Keeping the key in sessionStorage covers one more case: the guest reloads during the request. The reloaded page finds the key, asks GET /api/bookings/attempts/:key for its status, and shows the confirmation instead of a fresh form. The key is cleared only when the attempt reaches a final state. The retries-and-backoff lesson covers the delay schedule.

Sequence diagram of booking with a lost response, between the Guest, the Checkout page, the Processor script, and the Booking API. The guest presses Confirm and pay. Checkout asks the processor script to make a payment method and gets pm_1Qx back. Checkout sends POST with idempotency key k1 to the Booking API. The response is lost and the request times out. Checkout tells the guest it is checking the booking. It retries with the same key k1, and the Booking API returns the stored result: confirmed, without booking or charging again. Checkout shows the trip as confirmed.GuestCheckoutProcessorBooking APIConfirm and paymake pm_ idpm_1QxPOST, key k1timeout: lostchecking bookingretry, same k1stored: confirmedtrip confirmed
The timeout is the failure path: the booking had already succeeded. Because the retry carries the same Idempotency-Key, the server returns the first result instead of charging twice.

Instant book versus request to book

StepInstant bookRequest to book
What the guest sendsPayment method, quote id.Payment method, quote id, and a required message to the host.
What the server doesReserves nights, charges or authorizes, confirms.Authorizes the payment (or just validates the card) and creates a pending request. Nights are not blocked yet.
What the guest sees"You're going to Lisbon!" with the booking details."Request sent. The host has 24 hours to respond. You won't be charged unless they accept."
Later eventsNone needed for this flow.Accepted (booking confirmed, charge captured), declined, or expired. Shown by email, push, and in Trips.

The state machine below shows every state the checkout button can be in. Writing it down before coding is the cheapest way to make sure each server answer has a screen, including the awkward ones: price changed, dates gone, card declined, and unknown.

State machine of checkout. Ready goes to Submitting when the guest presses Pay. Submitting can go to Needs 3DS and back when the bank challenge is done. If every retry with the same key times out, Submitting goes to Checking, which polls the attempt's status by key and moves on when the result is found. Submitting can go to Price changed, which returns to Ready when the guest accepts the new total; to Declined, where the guest picks another card; to Unavailable, which sends the guest back to the calendar; to Pending host for a request to book; or to Confirmed for instant book.Readyquote validSubmittingbusy, retriesNeeds 3DSbank challengePrice changedold vs new totalCheckingpoll by keyDeclinedpick another cardUnavailableback to calendarPending hostrequest sentConfirmedinstant bookPay3DSnew totalacceptdates gonerequesttimeoutsfound
Each server status has a state and a screen. Only Confirmed and Pending host are final; Checking never claims failure, it asks the server by key and moves to whatever state the answer names.

Check your understanding · Exercise 9

The booking request times out after 20 seconds. What should the client do? Select all that apply.

Wishlists: saving that feels instant

Every card, marker card, and listing page has a heart. The first tap asks which wishlist ("Lisbon in March", or create a new one); later taps on the same stay remove it. Guests tap hearts often and quickly while scanning results, so the heart must react in the same frame, even on a slow connection.

This is the optimistic-updates lesson applied: update the cache now, send the request, and roll back if it fails. Three details make it specific to this page.

  • One fact, many hearts. The same stay can show as a list card, a marker card, and in the listing page cached for Back. Keep membership as one server-cache entry, a map from listing id to the wishlist ids that contain it. Every heart reads that entry, so one tap updates all of them.
  • Fast double taps. Tap, tap: save then remove within 300 ms. If the two requests can arrive in either order, the final state on the server is random. Serialize writes per listing, so the second request waits for the first, and send the desired final state (PUT to save, DELETE to remove) instead of a toggle, so a retried request can never flip the state twice.
  • Logged-out guests. A tap opens the login dialog. After login, the pending save completes and the dialog returns focus to the heart. Do not silently drop the guest's intent.
Sequence diagram of saving to a wishlist that fails, between the Guest, the Heart button, the Server cache, and the API. The guest taps the heart. The heart writes saved, optimistically, to the cache, and every heart for that stay fills at once. The cache sends PUT saved true to the API. The API answers with a 500 error. The cache rolls back and the hearts empty again. The heart tells the guest: Couldn't save, try again.GuestHeartCacheAPItap heartoptimistic: savedall hearts fillPUT saved=true500 errorroll backcouldn't save
The cache is the one place that knows the stay is saved, so a single write fills every heart on the page, and a single rollback empties them all.

The rollback must be visible but calm: put the heart back, and show a small message ("Couldn't save. Try again.") in a polite live region. Never leave a heart filled that the server does not have, because the guest will look for that stay in their wishlist next week and not find it.

Check your understanding · Exercise 10

A guest taps the heart on a card twice quickly, meaning "save, no, unsave". The save request is slow and arrives at the server after the unsave. What prevents the stay ending up saved? Pick one.

Phone and desktop layouts

On a 1,440 px desktop, the list takes about 60% of the width and the map the rest, sticky so it stays in view while the list scrolls. On a 390 px phone, two side-by-side panes would each be useless. The phone layout has to choose which view comes first and how to switch.

Decision How should the phone show the list and the map?

A list with a Map button

The list fills the screen. A floating "Map" button swaps to a full-screen map, and a "List" button swaps back.

Pros

  • Simple. Each view gets the whole screen.
  • The map code loads only after the tap.

Cons

  • Map and list are never visible together, so the spatial sense is lost.
  • Each swap is a big context switch.

A bottom sheet over a full-screen map

The map fills the screen behind a sheet of results. The sheet has three resting heights: a peek bar ("Over 1,000 places"), half, and full. Dragging the sheet down reveals the map; panning the map collapses the sheet to the peek bar.

Pros

  • Map and list stay one gesture apart. Panning the map updates the sheet's count right away.
  • Feels like a native app.

Cons

  • Gesture conflicts between the sheet's drag and the list's scroll.
  • Harder to make accessible: a sheet is a custom widget.

What we pick: The bottom sheet, opening at full height so the first screen is the list (the fast, accessible view) and the map code loads only when the guest drags the sheet down. Keep a visible "Map" button on the sheet as well, because a drag gesture alone is not discoverable and not usable for everyone.

Wireframe of the phone search page in three sheet heights. Full: a search pill reads Lisbon, Mar 10-14, and a sheet covers the screen with three result cards and a Map button. Half: the map shows price pills at the top, and the sheet covers the bottom half with one card. Peek: the map fills the screen with price pills, and only a bar at the bottom reads 312 stays.fullLisbon, Mar 10-14€631 total€540 total€702 totalMaphalfLisbon, Mar 10-14€631€540€631 totaldrag or tap handlepeekLisbon, Mar 10-14€631€540€702€488312 stayslist firstboth at oncemap first
One screen with a sheet at three resting heights. It opens at full, so the first view is the fast, accessible list; the map code loads only when the guest pulls the sheet down or taps Map.

Making the sheet behave

  • Animate with transforms. Move the sheet with transform: translateY(...) so the browser can composite it on the GPU without layout. Changing height or top on every frame triggers layout and drops frames on a mid-range phone.
  • Hand off between sheet drag and list scroll. When the sheet is at full height and the list is scrolled to the top, a downward drag moves the sheet; otherwise it scrolls the list. Use pointer events and touch-action on the handle, and snap to the nearest resting height on release, using the release speed so a quick flick goes all the way.
  • Respect the viewport. Use dvh units (dynamic viewport height) so the sheet's resting heights stay right as the phone's address bar shows and hides.
  • Give buttons to every gesture. The handle is a real button that cycles heights, and the sheet announces its state ("Results list, expanded"). With prefers-reduced-motion, snap without animating.
  • Remember the state. The sheet height is UI state, not search state: keep it in memory so Back from a listing restores it, but not in the shared URL.

Breakpoints switch the layout, not just the sizes: below about 744 px the sheet layout, above it the split view. Both render the same list and map components with the same store; only the container differs. That is the payoff of keeping state out of the components in the search-state chapter.

Think first

On a tablet in portrait mode (about 800 px wide), should the page show the split view or the sheet?

Architecture: the parts and how data flows

With the hard parts decided, draw the boxes. The app is three routes (search, listing, checkout) on a shared shell, talking to a BFF (backend for frontend: a thin server layer owned by the web team that shapes data for the pages) in front of the search, pricing, availability, booking, and wishlist services.

Architecture of the Airbnb web app. In the browser: the Router with the URL codec parses the URL and feeds the SearchPage. The SearchPage reads the UI store for hover and selection, and runs queries in the Server cache. The SearchPage renders the ResultsList and the MapView; the MapView sends many points to a Worker for clustering. The Server cache calls the API client, which adds abort signals and idempotency keys. Card iframes hold the hosted payment fields. Outside the browser: the API client goes through the CDN to the BFF, which renders pages and fans out to five services: search, pricing, availability, booking, and wishlists. The card iframes talk only to the payment processor.BrowserRouter + URLparse, canonicalUI storehover, selectionSearchPagesplit or sheetServer cachequery libraryResultsListMapViewMapAdapterAPI clientabort, idem keyWorkerclusteringCard iframeshosted fieldsSearchEdge and serversCDNHTML, tilesBFFSSR, fan-outServices5 of themProcessorpayments
Only the SearchPage knows that both a list and a map exist; each view reads the same cache and store. Card data goes from the hosted iframes straight to the processor and never passes through our code.
PartResponsibilityWhy the boundary is here
Router and URL codecParses the URL into a typed Search and serializes it back, canonical and validated.The URL is the source of truth; one module owns its format so no component writes query strings by hand.
Search page shellLays out the search bar, list, and map (split or sheet); reads the Search; runs the results query.The only place that knows both views exist. List and map stay unaware of each other.
Results listRenders cards, paging, empty and error states; writes hoveredId.The accessible, fast view. Works with the map missing.
Map view and MapAdapterWraps the map library: set bounds, draw pills and dots, cluster, report settled bounds, highlight an id.Swapping Google Maps for MapLibre touches one file. The rest of the app never imports the library.
Cluster workerBuilds the supercluster index off the main thread when there are many points.Indexing tens of thousands of points would block input for tens of milliseconds.
UI storehoveredId, selectedId, sheet height, picker open state.Shared, short-lived state that is neither server data nor search state.
Server cache (query library)Results, listings, availability, quotes, wishlists; keyed by canonical inputs; dedupes, cancels, keeps previous data.Server data has its own rules: staleness, refetching, races. One library handles them consistently.
API clientTyped fetch wrapper: auth, AbortSignal, idempotency keys, error mapping.One place to add headers, retries, and logging.
Payment fieldsThe processor's hosted iframes and script.Card data must never pass through our code.
BFFServer rendering, CDN caching headers, one call per page that fans out to services.Pages need data from five services; the browser should make one request, not five.

How data moves for the search page

Follow one search end to end. The first visit is server rendered: the CDN or BFF renders the list for the URL's search, and the page hydrates with that data already in the server cache, so the client does not fetch it again. After that, every change goes through the same loop: input changes the URL, the URL changes the query key, the query fetches through the BFF, the cache updates, and both views re-render from it.

Data flow for the search page. The guest pans or picks dates, which changes the URL. The URL becomes a canonical query key in the server cache. On a miss, the cache sends GET /api/search to the BFF, which fans out four calls to the services and gets their data back. The BFF answers with 20 cards and up to 500 dots, about 30 KB. The cache gives cards to the ResultsList and cards plus dots to the MapView. A note says writes go the other way: a heart tap updates the cache first, then sends a PUT.guestURL?ne=..&zoom=13Server cachekey = queryBFFSSR, fan-outServicessearch, pricingResultsListMapViewpan, dateskeyGET /api/search30 KB JSON4 callscardscards + dotswrites: heart tapcache, then PUT
Every search takes the same path: input, URL, key, request, cache, both views. Back to an earlier search is a cache hit, because its URL gives the same key.
  • Down the wire: one request per settled search. GET /api/search with the canonical query: about 2 KB of URL at most, answered with 20 cards, up to 500 dots, and the total count, about 25 to 40 KB compressed.
  • Into the cache, by key. The canonical query string is the key. Back to a previous search is a cache hit with no network.
  • Out to the views, by selector. The list reads cards; the map reads cards for pills and dots for the cluster layer. Hover writes go to the UI store, never to the server cache.
  • Writes go the other way. Wishlist taps write optimistically to the cache and then to the API. Bookings go straight to the API with an idempotency key and are never optimistic: money waits for the server.

Think first

Why put a BFF between the browser and the services, instead of calling search, pricing, and wishlist services directly from the browser?

Data model: entities and where each lives

The client holds six kinds of data, plus a little UI state and the booking attempt key. The rule for placing each one is the same as in the search-state chapter: search inputs in the URL, server data in the server cache keyed by its inputs, shared short-lived UI state in a small store, and drafts in the component that edits them.

ts
type Day = string;   // "YYYY-MM-DD"
type LatLng = [lat: number, lng: number];

type ListingCard = {           // one search result
  id: string; title: string; kind: string;          // "Entire apartment"
  photos: { url: string; w: number; h: number }[];  // first 5, sized
  position: LatLng;            // approximate, not the address
  rating?: { avg: number; count: number };
  price: { total?: Money; nightly: Money; indicative: true };
  instantBook: boolean;
};
type MapDot = { id: string; position: LatLng };    // matches beyond this page

type SearchResults = { cards: ListingCard[]; dots: MapDot[]; total: number; page: number };

type Listing = ListingCard & {  // listing page
  description: string; amenities: string[]; houseRules: string[];
  host: { id: string; name: string; avatarUrl: string; superhost: boolean };
  timeZone: string;            // "Europe/Lisbon"
  maxGuests: number; minNights: number; maxNights: number;
};
type Availability = { from: Day; nights: { day: Day; free: boolean; minNights?: number }[] };
type WishlistIndex = Record<string, string[]>;      // listingId -> wishlist ids
Client-side shapes. A card is a slim projection of a listing; the full listing loads only on its page.
The client data model grouped by where each entity lives. In the URL: Search, with place, days, and bounds. In the server cache: SearchResults keyed by the query, holding 20 ListingCards whose price belongs to the search; Listing keyed by id, locale, and currency; Quote, valid until expiresAt; Availability as a list of nights; and WishlistIndex, shared by listing id. In the UI store: hover, selection, and sheet height. In sessionStorage: the booking attempt key.URLSearchplace, days, boundsUI storeUI statehover, sheetsessionStorageattempt keyServer cacheSearchResultskey: queryListingCard x20price per searchListingid, locale, curQuoteuntil expiresAtAvailabilitynights, 60 sWishlistIndexshared by idclick a card
Prices belong to a search, so results are cached by the whole query and never merged by listing id. Only wishlist membership is shared across every view.
EntityLives inKeyFreshness
SearchURLthe URL itselfAlways current by definition.
SearchResultsServer cachecanonical query stringFresh for about 60 s; previous kept while the next loads.
ListingServer cache, seeded by server renderinglisting id + locale + currencyMinutes. Content changes rarely.
AvailabilityServer cachelisting id + month rangeAbout 60 s, and refetched before checkout.
QuoteServer cache, then checkout pagelisting + dates + guests + currencyUntil expiresAt. Never reused after it.
WishlistIndexServer cache, optimistic writesuser idUpdated by our own writes; refetched on focus.
Hover, selection, sheet heightUI storenoneGone on reload, by design.
Booking attempt keysessionStoragequote idUntil the attempt reaches a final state.

Two choices are worth defending. First, cards are not normalized into a shared listing store keyed by id, the way a social feed normalizes posts. A card's price belongs to its search (dates and guests), so the same listing can show two different totals in two cached searches, and both are right.

Keying results by the whole query keeps each snapshot honest. Only wishlist membership, which is the same in every search, is shared by id.

Second, availability is a list of nights with optional per-night minimum stays, not a list of booked ranges. The calendar asks one question per cell ("is this night free, and what is its minimum stay?"), and a flat list answers it in constant time by index from from. Ranges would need a search per cell and are easy to get wrong at their edges.

Check your understanding · Exercise 11

Which statements about this data model are right? Select all that apply.

Interface: the API and the main component contracts

The browser talks to the BFF over JSON REST. GraphQL would also work; what matters is the shapes, the cache rules, and the errors. Here are the calls the three pages make.

CallPurposeCaching
GET /api/search?place&checkin&checkout&adults&...&ne&sw&zoom&pageCards for one page, dots for the area, total count.private, max-age=60. Also cached by the query library.
GET /api/listings/:idFull listing content for the page (server side) or a client navigation.CDN: public, s-maxage=300, stale-while-revalidate=600, varied by locale and currency.
GET /api/listings/:id/availability?from&months=12Nights with free flag and minimum stay.private, max-age=60.
POST /api/quotesPrice for listing, dates, guests, currency. Returns a Quote.Not cached. Safe to repeat: the same inputs give an equivalent quote.
POST /api/bookings with Idempotency-KeyBook against a quote with a payment method.Never cached. Safe to retry with the same key.
GET /api/bookings/attempts/:keyStatus of an attempt after a timeout or reload.No cache.
PUT /api/wishlists/:wid/items/:listingId and DELETE on the same pathSave or unsave, as a target state.Not cached. Idempotent.
json
// request
{ "quoteId": "q_7Hk2", "paymentMethodId": "pm_1Qx",
  "messageToHost": "Hi! Two of us, arriving by train around 4 pm." }

// 409 response
{ "status": "price_changed",
  "message": "The price for these dates changed.",
  "oldTotal": { "amount": 63072, "currency": "EUR" },
  "quote": { "id": "q_9Lm4", "total": { "amount": 65510, "currency": "EUR" },
             "lines": [ { "kind": "nights", "label": "€126 x 4 nights",
                          "amount": { "amount": 50400, "currency": "EUR" } } ],
             "expiresAt": "2026-03-01T10:42:00Z" } }

// other statuses: "confirmed", "pending_host", "requires_action",
// "unavailable", "payment_declined", "quote_expired"
POST /api/bookings: the request, and the answer when the price changed. Every answer has a status the client switches on.

Errors are part of the contract, not exceptions. Each status maps to one state in the booking state machine, with its own message and next step. A generic 500 maps to "unknown", which polls by key instead of claiming failure.

Component contracts

ts
type MapViewProps = {
  cards: ListingCard[];            // DOM price pills
  dots: MapDot[];                  // WebGL layer, clustered
  hoveredId?: string;
  selectedId?: string;
  initialBounds?: Bounds;          // from the URL, applied once per search
  onSettle(bounds: Bounds, zoom: number, byUser: boolean): void;
  onSelect(id: string | null): void;
};

type DateRangePickerProps = {
  value: { checkin?: Day; checkout?: Day };
  isNightFree(day: Day): boolean;
  minNightsFrom(day: Day): number;
  maxNights: number;
  today: Day;                      // in the listing's time zone
  locale: string;
  onChange(value: { checkin?: Day; checkout?: Day }): void;
};
The map and the date picker are the two components worth defining precisely. Neither knows about the URL or the API.

onSettle reports byUser so the page can ignore camera moves the app made itself. initialBounds is applied when the search changes from outside (a new place typed, Back pressed), not on every render, or the map would fight the guest's drag.

Accessibility: the map is not the only way in

A map is a visual, pointer-first interface. Everything drawn inside its WebGL canvas is invisible to screen readers, and panning by keyboard is slow at best. The answer is not to make the map perfect for everyone; it is to make sure nothing is only on the map.

The list is the accessible alternative

  • Same data, same order. The list shows every stay that has a price pill, in ranked order, with the price, rating, and kind in text. A screen reader user loses nothing by skipping the map.
  • Let people skip the map. Put the list before the map in the DOM and tab order, and add a "Skip map" link before the map region. Label the region (role="region" with aria-label="Map of results") so it shows in landmark navigation.
  • Announce result changes. After each search, update a polite live region: "Showing 20 of 312 stays in this area." Announce only the settled result, not the loading, or a panning user hears a stream of messages.
  • Describe the area in words. Above the list: "Stays in Lisbon, map area around Alfama". A guest who cannot see the map still knows what the results are for.
  • A filter, not a gesture. Searching an area must not require dragging. The place field, a neighborhood filter, and "Search this area" with keyboard zoom cover it.

Keyboard support on the map itself

The map is still useful for many keyboard users with sight, so it should work without a mouse. Map libraries already pan with arrow keys and zoom with + and - when the map has focus. On top of that:

  • Price pills are buttons. Each DOM pill is a button with an accessible name such as "€631 total, Entire apartment in Alfama, 4.9 stars". They are in the tab order after the map controls, in the same order as the list.
  • Opening a pill moves focus into its card, and Escape returns focus to the pill. The card's heart and link are reachable by Tab.
  • Clusters are buttons too: "Cluster of 24 stays, zoom in". Activating one zooms in to its expansion zoom.
  • Dots stay out of the tab order. The hundreds of unpriced dots are a visual hint; tabbing through them would be a trap. The list and the paging cover them.
  • Visible focus. A thick outline on the focused pill, drawn above all other pills, even inside a crowded area.

The rest of the page

The date picker follows the grid pattern in the dates chapter. The guest picker's steppers are buttons with names and a visible count. The photo tour is a dialog with focus trapped and returned, and the bottom sheet's handle is a button.

Price changes in the booking box are announced politely ("Total updated: €655.10"), and the price-changed step at checkout moves focus to its heading so a screen reader user cannot miss it. Motion (sheet snaps, map fly-to animations) respects prefers-reduced-motion: the map jumps instead of flying. Color is never the only signal: a hovered or selected pill also changes its border and stacking, not just its fill.

Check your understanding · Exercise 12

Which changes make the search page usable for a screen reader user? Select all that apply.

Performance: budgets for a phone on 4G

The core-web-vitals lesson covers the metrics. This chapter sets budgets for this app and shows where each page spends them. The phone is the budget setter: a CPU several times slower than a developer laptop, and 4G with 50 to 100 ms of round-trip time on a good day.

PageLCP elementMain riskBudget and tactic
ListingFirst photoLate photo discovery, big JS before it.LCP under 2.5 s. Photo URL in the server HTML with fetchpriority="high"; JS for the page under about 150 KB compressed before interaction; map, reviews dialog, and photo tour split into lazy chunks.
SearchFirst card's photoMap library competing for bandwidth and CPU.List server rendered; map library loaded after hydration on desktop and on demand on phones; first-card photo prioritized, the rest lazy.
CheckoutTrip summary textPayment script cost on the main thread.Load the processor's script early on this page only (preconnect, then script), never site-wide.

The JavaScript budget

On a mid-range phone, parsing and running JavaScript is often the slowest step, more than downloading it. A map library alone can be several hundred KB. If it is in the main bundle, every listing page view on a phone pays for a map the guest may never scroll to. Split by route and by interaction:

  • Route chunks. Search, listing, and checkout each load their own code. The map library is its own chunk, shared by search and the listing's location section.
  • Interaction chunks. The date picker loads when the dates field is focused or hovered (start on intent, not on click, to hide the latency), the photo tour when "Show all photos" is hovered or focused, the reviews dialog on demand.
  • Third parties last. Analytics, chat, and experiments load after the page is interactive, and never block hydration.
  • Check it in CI. A size check fails the build when a route's initial JS grows past its budget. Budgets that are not enforced only describe how big things used to be.

Keeping interactions under 200 ms

  • Hover is cheap or it is wrong. A hover must re-render two markers and one card, not the list or the map. Measure it: a mouse sweep across 20 cards should not produce long tasks.
  • Swap results in one render. When a search lands, update the list and the pills in the same commit, wrapped in a transition (startTransition in React) so a key press or drag in progress stays responsive.
  • Do not re-layout during a drag. No list changes while the map moves (debounce again pays off), and DOM pills move with transform only.
  • Index clusters off the main thread when there are thousands of points, as the map chapter showed.

Network tactics

  • Prefetch the likely next page. Hovering a card for 200 ms or more prefetches its listing data (not its photos), so the click opens with content. On phones, prefetch the first two cards' data when the list settles. The cost is a few KB of JSON; the gain is a listing page that renders from cache.
  • Reuse server data. The search results rendered on the server are handed to the client cache during hydration, so the first client render does not refetch them.
  • Cache tiles hard. Map tiles are immutable per version; serve them from a CDN with long cache lifetimes so a returning guest's map draws from disk.
  • Measure in the field. Collect LCP, INP, and CLS from real visits with the web-vitals library, split by page and by device class, plus custom marks: time from map settle to results painted, and quote latency. Lab tests on a fast laptop miss exactly the phones this budget is for.

Check your understanding · Exercise 13

Field data shows a poor INP on the search page, only on phones, mostly while the guest drags the sheet or the map. Which causes are likely? Select all that apply.

Running it in the interview

This problem is too big to cover fully in 45 or 60 minutes. The interviewer is watching whether you find the hard parts, go deep on two or three of them, and keep the rest at the right height. Here is a plan for 60 minutes; for 45, shrink the middle.

MinutesWhat to doWhat to draw or say
0 to 6Requirements. Ask whether the map drives the search, whether prices are totals, instant book or request, devices, and SEO.Write the functional list and four numbers: LCP 2.5 s, INP 200 ms, 20 results per page, results within 500 ms of a pan.
6 to 10Name the hard parts and the one-line summary."The map is an input, the URL is the truth, prices are quotes, booking is idempotent."
10 to 18Architecture and data flow.The architecture diagram: routes, URL codec, server cache, UI store, MapAdapter, BFF and services.
18 to 32Deep dive 1: search as you move.The pan sequence: settle, debounce, abort, keep previous results, hover through one store. Mention clustering and DOM pills versus WebGL dots.
32 to 44Deep dive 2: price and booking.The quote timeline and the booking state machine with the idempotency key and the price-changed path.
44 to 52Dates, or whatever the interviewer steers to.Nights versus days, the date-only bug, the grid keyboard pattern.
52 to 60Performance and accessibility, then questions.Lazy map, LCP photo, JS budget; the list as the accessible alternative.

Follow-up questions and strong answers

Interviewers use the last third of the time to push on an edge. The questions below come up often for this problem. Try each one before opening the answer: a strong reply names the failure first, then the mechanism that handles it, then the cost.

Think first

The guest is in a train tunnel and pans the map. What happens?

Think first

The guest has the same listing open in two tabs and presses Pay in both. What prevents two bookings?

Think first

Should search result pages be indexed by search engines?

Think first

The interviewer says: "Show every match on the map with its price, up to 3,000." How do you change the design?

Think first

A guest switches currency from euros to US dollars on the listing page. What changes?

Common mistakes

  • Spending 20 minutes on the search bar's styling and never reaching the map loop or the booking flow.
  • Fetching on every map move event, or not cancelling stale searches.
  • Keeping map position only inside the map library, so Back and sharing break.
  • Computing prices on the client, or storing money as floats.
  • Using new Date("YYYY-MM-DD") for calendar days.
  • Treating a booking timeout as a failure and inviting a second payment.
  • Making the map the only way to see results.

Summary

  • The map is an input. Debounce its settle event, cancel stale searches, keep previous results while loading, and ignore camera moves the app makes itself.
  • The URL is the source of truth for the search: place, dates, guests, filters, rounded bounds, zoom, and page. Map moves replace history; deliberate changes push it.
  • List and map read one result and one small UI store. Hover changes two markers, not twenty.
  • Draw the current page as DOM price pills and the rest of the area as clustered WebGL dots; load the map library lazily and keep one instance.
  • Dates are calendar days as YYYY-MM-DD strings, availability is about nights, and "today" is in the listing's time zone.
  • Server render and CDN-cache the shared listing content; load price, availability, and wishlist state on the client.
  • Prices are server quotes with ids and expiry. The client formats money and never computes it; any change is shown before payment.
  • Book with hosted payment fields and an idempotency key kept across retries and reloads. A timeout means unknown, not failed.
  • Nothing lives only on the map: the list is the full accessible alternative.

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 5
Mid-level 0 of 6

References

Concepts behind it

Try next