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.
| Question | Answer we assume | What 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.
- 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.
- 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.
- 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.
- 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
Datecan show the guest the 9th, quietly. Night counts, blocked days, and minimum stays all depend on getting this right. - 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.
- 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.
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?
It has no map as an input, so it has no stream of searches caused by dragging and no second view to keep in sync. Its requests come from deliberate clicks, a few per minute, not dozens per second.
It usually has no date logic: a product is in stock or not, with no calendar days, minimum nights, or time zones.
Its price is mostly stable: a shop item costs the same today and tomorrow, while a stay's total depends on dates, guest count, length of stay, fees, and taxes. The money and double-booking traits partly apply to any checkout, but a hotel night is unique inventory: two people cannot buy the same one.
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?
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
| State | Lives in | Why |
|---|---|---|
| Place, dates, guests, filters | URL query | Defines the search. Must survive refresh and sharing. |
| Map bounds and zoom (settled) | URL query | The visible area is part of the search when "search as I move" is on. |
| Map camera during a drag | Map library | Changes 60 times a second. Only the settled value matters. |
| Page number | URL query | Back from a listing must return to page 3, not page 1. |
| Results for a query | Server cache keyed by canonical URL | Server data. Back reuses the cached page instantly. |
| Hovered stay id, open marker card | Small client store | Shared by list and map, but meaningless in a shared link. |
| Wishlist membership | Server cache, updated optimistically | Server data that the user can change. |
| Draft dates in an open picker | Picker component state | Not 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.
replaceState, so Back undoes a decision rather than a nudge. The canonical key makes two equal searches one cache entry.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
};
}
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.
Step 1: when to send the search
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?
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.
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;
}
}
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.
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.
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.
pointerleaveon the list clearshoveredId, 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.
| Aspect | Raster tiles | Vector tiles |
|---|---|---|
| What arrives | A 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 it | The server, once. The browser only places images. | The client, with WebGL, on every frame. |
| Zooming between levels | Blurry until the next level's images load. | Sharp at every fractional zoom; smooth rotation and tilt. |
| Styling and language | Baked 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 cost | Small library, little CPU or GPU. | Bigger library (hundreds of KB), GPU work, and more memory. |
Decision Which map library?
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?
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.
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.
- 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.
- 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.
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 addpreconnecthints 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?
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.
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.
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")}`;
}
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,HomeandEndgo to the start and end of the week,Page UpandPage Downchange the month, andShiftwithPage UporPage Downchanges 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 usearia-selected, blocked onesaria-disabledso 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().firstDaygives the answer, but it reached all major engines only recently (Firefox added it in 2026, and older Chrome versions had aweekInfoproperty instead), so feature-detect both and keep a small fallback table by region. Month and weekday names come fromIntl.DateTimeFormatfor 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?
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.
Each part of the page, and how it loads
| Part | How it loads | Notes |
|---|---|---|
| Photo gallery (first 5) | Server HTML | First photo is the LCP element: fetchpriority="high", never lazy, srcset with sizes. Others are below it in priority. |
| Title, host, highlights, description | Server HTML | Indexed text. Long descriptions are cut with a "Show more" button, but the full text is in the HTML. |
| Amenities | Server 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 reviews | Server HTML | Average, count, and category scores are SEO content. More reviews load in a dialog with cursor pagination. |
| Availability calendar | Client fetch after hydration | One call for the next 12 months, cached for a minute. It changes when anyone books. |
| Booking box price | Client fetch per dates and guests | A quote from the server, never computed on the client. See the next chapter. |
| Location map | Static image, then interactive on demand | An approximate area, not the address, until booking. The map library loads only when the section nears the viewport. |
| Wishlist heart | Client fetch | Personal. The CDN copy of the page must never contain it. |
The photo gallery
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 apreloadlink 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=photoswithpushState, 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: autorow withscroll-snap-type: x mandatorygives native swipe physics. Script only updates the counter, usingIntersectionObserveron 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.
€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
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?
Because then the cached HTML would contain one guest's wishlist state, and the CDN would serve it to everyone. Either we stop caching the page (every view becomes a full server render), or we leak and mix up personal state.
The fix for the flash is cheap: render the heart in a neutral, non-interactive state with its size reserved, and fill it as soon as the small wishlist call returns. Since the heart is a few pixels, there is no layout shift and almost no one notices.
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?
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
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);
};
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.
- 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?
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:
- Mount the hosted fields; show their validation errors inline ("Your card number is incomplete").
- 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. - Send the booking request with the quote id, the payment method id, and an idempotency key.
- 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.
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
}
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.
Idempotency-Key, the server returns the first result instead of charging twice.Instant book versus request to book
| Step | Instant book | Request to book |
|---|---|---|
| What the guest sends | Payment method, quote id. | Payment method, quote id, and a required message to the host. |
| What the server does | Reserves 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 events | None 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.
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 (
PUTto save,DELETEto 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.
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?
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.
Making the sheet behave
- Animate with transforms. Move the sheet with
transform: translateY(...)so the browser can composite it on the GPU without layout. Changingheightortopon 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-actionon 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
dvhunits (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?
At 800 px, a 60/40 split leaves the list about 480 px wide (one card per row) and the map about 320 px wide (too narrow to pan comfortably). The sheet usually gives a better result here, or a split with the map hidden behind a toggle.
The deeper point is to decide by available width (a container or media query), not by device type. A desktop browser window snapped to half a screen is the same case.
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.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Router and URL codec | Parses 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 shell | Lays 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 list | Renders cards, paging, empty and error states; writes hoveredId. | The accessible, fast view. Works with the map missing. |
| Map view and MapAdapter | Wraps 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 worker | Builds 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 store | hoveredId, 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 client | Typed fetch wrapper: auth, AbortSignal, idempotency keys, error mapping. | One place to add headers, retries, and logging. |
| Payment fields | The processor's hosted iframes and script. | Card data must never pass through our code. |
| BFF | Server 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.
- Down the wire: one request per settled search.
GET /api/searchwith 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 readscardsfor pills anddotsfor 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?
A search card needs data from several services: search for the ids and ranking, pricing for the totals, listings for photos and titles, wishlists for the heart. From the browser that would be four requests on a slow phone, some of which depend on others. The BFF makes them in the data center, where each call takes a few milliseconds, and returns one shaped response.
It also gives the web team one place for server rendering, cache headers, and response shapes tuned for the page, without asking every service team to change their APIs.
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.
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
| Entity | Lives in | Key | Freshness |
|---|---|---|---|
| Search | URL | the URL itself | Always current by definition. |
| SearchResults | Server cache | canonical query string | Fresh for about 60 s; previous kept while the next loads. |
| Listing | Server cache, seeded by server rendering | listing id + locale + currency | Minutes. Content changes rarely. |
| Availability | Server cache | listing id + month range | About 60 s, and refetched before checkout. |
| Quote | Server cache, then checkout page | listing + dates + guests + currency | Until expiresAt. Never reused after it. |
| WishlistIndex | Server cache, optimistic writes | user id | Updated by our own writes; refetched on focus. |
| Hover, selection, sheet height | UI store | none | Gone on reload, by design. |
| Booking attempt key | sessionStorage | quote id | Until 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.
| Call | Purpose | Caching |
|---|---|---|
GET /api/search?place&checkin&checkout&adults&...&ne&sw&zoom&page | Cards for one page, dots for the area, total count. | private, max-age=60. Also cached by the query library. |
GET /api/listings/:id | Full 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=12 | Nights with free flag and minimum stay. | private, max-age=60. |
POST /api/quotes | Price 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-Key | Book against a quote with a payment method. | Never cached. Safe to retry with the same key. |
GET /api/bookings/attempts/:key | Status of an attempt after a timeout or reload. | No cache. |
PUT /api/wishlists/:wid/items/:listingId and DELETE on the same path | Save or unsave, as a target state. | Not cached. Idempotent. |
// 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"
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
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;
};
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"witharia-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
buttonwith 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
Escapereturns focus to the pill. The card's heart and link are reachable byTab. - 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.
| Page | LCP element | Main risk | Budget and tactic |
|---|---|---|---|
| Listing | First photo | Late 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. |
| Search | First card's photo | Map 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. |
| Checkout | Trip summary text | Payment 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 (
startTransitionin 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
transformonly. - 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-vitalslibrary, 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.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 6 | Requirements. 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 10 | Name 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 18 | Architecture and data flow. | The architecture diagram: routes, URL codec, server cache, UI store, MapAdapter, BFF and services. |
| 18 to 32 | Deep 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 44 | Deep dive 2: price and booking. | The quote timeline and the booking state machine with the idempotency key and the price-changed path. |
| 44 to 52 | Dates, or whatever the interviewer steers to. | Nights versus days, the date-only bug, the grid keyboard pattern. |
| 52 to 60 | Performance 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?
The search request fails or times out. We keep the previous results on screen, show "You're offline. Results are for the previous area." near the list, and keep the map where the guest put it. When the online event fires, or the guest pans again, we retry the current URL's search.
Nothing breaks, because the URL already holds the new area: the retry needs no extra state, and if the guest reloads later the page loads the right area.
Think first
The guest has the same listing open in two tabs and presses Pay in both. What prevents two bookings?
Each tab usually has its own sessionStorage (a duplicated tab starts with a copy), so each makes its own idempotency key: the key alone does not save us here. The server does: reserving the nights is atomic, so the second request finds the nights taken by the first booking and answers unavailable, or the server detects the same guest, listing, and dates and returns the existing booking.
The client's part is to show that answer well: "You already booked these dates" with a link to the trip, not a generic error. Idempotency keys stop retries from duplicating; the server's inventory rules stop parallel attempts.
Think first
Should search result pages be indexed by search engines?
Not every URL. There are endless combinations of bounds, dates, and filters, and indexing them would waste crawl budget on near-duplicate pages. Instead, build stable landing pages per city and neighborhood ("Stays in Lisbon") without dates or bounds, server rendered with a canonical URL, linking to listings.
Dated and map-bounded search URLs get a canonical link pointing to their place's landing page, or noindex. Listing pages are the main SEO surface and are fully server rendered.
Think first
The interviewer says: "Show every match on the map with its price, up to 3,000." How do you change the design?
Move pills into the WebGL layer: a symbol layer with text labels for the price, collision detection on, so overlapping labels hide and reappear as the guest zooms. Hover and selection use feature state instead of re-rendering. Clusters with counts take over below a zoom threshold.
On the server, pricing 3,000 stays per search is the real cost, so ask whether prices could be cached per listing and date range for a few minutes. Accessibility does not change: the list remains the full alternative, with paging.
Think first
A guest switches currency from euros to US dollars on the listing page. What changes?
Currency is part of every price's inputs, so it goes into the URL (or a cookie for a preference) and into every query key. The quote is fetched again in dollars; the cached euro quote is not converted on the client.
The listing HTML at the CDN is varied by currency, so the switch is a cache miss once and a hit for everyone after. If the charge will still be in euros, checkout says so next to the total.
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-DDstrings, 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.
References
- WAI-ARIA APG: Date Picker Dialog example The keyboard and labeling pattern for a calendar grid.
- supercluster on GitHub The clustering library: options, defaults, and the getClusters and expansion zoom calls.
- MapLibre GL JS documentation Open-source WebGL vector maps: sources, layers, feature state, events.
- Google Maps JavaScript API: Advanced Markers HTML markers on Google Maps.
- MDN: AbortController Cancelling stale search and quote requests.
- MDN: Date.parse and date-only forms Why a date-only string becomes midnight UTC.
- Stripe API: Idempotent requests How an idempotency key makes payment retries safe.
- IETF draft: The Idempotency-Key HTTP Header Field The header being standardized.
- web.dev: Optimize resource loading with the Fetch Priority API Prioritizing the LCP photo.
Concepts behind it
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Dates, numbers, and pluralsTurning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.