cf.completefrontend
My progress
SYSTEM DESIGN · #93

Design a flight booking site

A site where people search flights from many airlines and agencies, compare and filter thousands of results, pick a flight and seats, and pay. The hard parts are a search that streams in over 5 to 30 seconds, fast filters over thousands of itineraries, prices that are cached quotes and can change on select, a multi-step booking racing a fare hold, and a booking request that must never charge twice.

The question

Design the frontend of a flight booking site like Kayak, Skyscanner, or Google Flights with a checkout. A traveller enters where and when they want to fly, sees flights from many airlines, sorts and filters them, opens one to see the details, chooses seats and extras, enters passenger details, pays, and gets a confirmation.

Our backend asks many airlines and agencies for every search, so the full answer takes up to 30 seconds. Prices come from caches and can change at any moment, including while the traveller is typing a passport name. Walk me through the search, the results page, and a booking flow that is honest about price and never books twice.

Real-life examples: Google Flights, Kayak and Skyscanner metasearch, Expedia and MakeMyTrip checkout, Airline sites such as Lufthansa and IndiGo

Requirements: what to ask before drawing anything

A flight site looks like a form and a list, but it is really three products glued together.

The first is a search engine that asks dozens of slow outside systems and waits for them. The second is a comparison tool where people slice thousands of results by price, time, and airline. The third is a checkout for a product whose price and stock change by the minute and where a mistake costs real money: a wrong name on a ticket can cost a fee to fix, and a double booking means two charges.

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

QuestionAnswer we assumeWhat it changes
Metasearch or a single airline?Metasearch with booking: we ask many airlines and agencies, then let the traveller book on our site.Results arrive from many sources at different speeds, so search is long-running and must be merged.
How long does a full search take?The first sources answer in 1 to 3 seconds, the slowest in up to 30. The backend gives up on a source after 30 seconds.We stream or poll partial results and show progress instead of one spinner.
How many results per search?A busy round trip such as London to New York returns 1,000 to 5,000 itineraries after merging.Decides whether filtering and sorting run in the browser or on the server.
Is the search price final?No. It is a cached quote that can be minutes or hours old. The real price is fetched when the traveller selects a flight and again at payment.We need re-pricing, an honest price-changed screen, and quote expiry.
Which trip types?One way, round trip, and multi-city with up to 5 legs. Up to 9 passengers: adults, children, infants.The search form and the URL must encode a list of legs and a passenger mix.
Do we sell seats and extras?Yes: seat maps, bags, and fare upgrades where the airline offers them.A seat map component and an extras step in the booking flow.
Who are the users and devices?Mostly mobile web for search, a large share of desktop for booking. Many markets, currencies, and some right-to-left languages.A tight JavaScript budget on mobile, Intl formatting, mirrored layouts.
Is SEO important?Yes. Pages like flights from Delhi to Mumbai bring a large share of new visitors from search engines.Server-rendered landing pages with real content, separate from the live search.

Functional requirements

  • Search by origin and destination (airport or city), trip type, dates, passengers, and cabin. Airport fields suggest as you type. The date picker can show a cheapest price per day.
  • See results as they arrive, with progress. Sort by best, cheapest, and fastest. Filter by stops, departure and arrival times, airlines, price, duration, and layover airports, with a count next to each filter value.
  • Open an itinerary to see each flight segment, layovers, local times, aircraft, baggage, and the fare families on offer (for example Basic, Standard, Flex).
  • Select a fare, get a confirmed price, then go through passenger details, seats, extras, and payment, with a visible timer for how long the price is held.
  • Get a confirmation with a booking reference (the 6-character PNR, or passenger name record) and an e-ticket by email.
  • Save recent searches and set a price alert for a route.

Non-functional requirements, with numbers

  • First results fast. The first results show within 3 seconds of submitting a search on a 4G phone, even though the full search takes up to 30.
  • Filters feel instant. Toggling a filter or a sort over 5,000 results updates the list within 100 ms, and INP (Interaction to Next Paint, the delay from a tap to the next frame) stays under 200 ms.
  • Stable while loading. Results that arrive later never move the row the traveller is reading or about to tap.
  • Honest price. The traveller is never charged more than the price shown on the payment step, and any price change is shown before payment, not after.
  • Exactly once. A double click, a timeout, or a reload during payment creates at most one booking and one charge.
  • Shareable and SEO friendly. Every search is a URL. Route landing pages have an LCP (Largest Contentful Paint) under 2.5 seconds.
  • Accessible. The whole flow, including the seat map, works with a keyboard and a screen reader, and meets WCAG 2.2 AA.

Out of scope

We leave out hotels and cars, the airline's own reservation system, fare rules engines, loyalty programs, check-in, and the back office that handles refunds. We also leave out building the fare search itself: to us it is a backend service that we call and wait for. Saying this out loud tells the interviewer you know where the frontend ends.

Check your understanding · Exercise 1

Which of these answers would change the frontend design the most? Select all that apply.

What makes flight booking hard

Before choosing anything, name the traits of this problem. Each one drives a later chapter, and saying them early shows the interviewer you see the real problem and not just a form and a list.

  • The search is slow and arrives in pieces. Our backend fans out one search to 20 to 100 providers: airlines, agencies, and fare aggregators. Some answer in a second, some in 30. The traveller should see the fast ones at once and watch the rest arrive, without the page jumping.
  • The same flight comes back many times. Three agencies may all sell the same two Lufthansa flights at different prices. The page must group them into one itinerary with several offers, or the list fills with copies.
  • The result set is big but bounded. A few thousand itineraries is too many to render as DOM rows, but small enough to hold in memory and filter in a few milliseconds. That middle size is unusual and it decides where filtering runs.
  • The price is a guess until it is not. Search prices come from caches. The airline gives the real price only when asked about one specific fare, and it can be higher, lower, or gone.
  • Inventory is held for a limited time. Once priced, a fare or seat may be held for 10 to 30 minutes. The booking flow races this clock, and the traveller must see it.
  • Time is local everywhere. A flight leaves Delhi at 23:40 local time and lands in London at 05:10 local time the next day. Every time on screen is in the airport's own time zone, and the arithmetic between them is a classic source of bugs.
  • Money and identity are at stake. A name that does not match the passport can mean being refused boarding or paying a change fee. A lost response during payment must not become two tickets.

Notice what these traits share: most of them are about time. Results arrive over time, prices age over time, holds expire over time, and flights cross time zones. A strong answer models each of these clocks explicitly instead of hoping the network is fast.

Two of the traits also pull against each other. Streaming results in wants the list to change all the time. Reading and comparing wants the list to stay still. The design answer, which chapter 4 builds, is to separate what has arrived from what is shown, and let the traveller choose when to merge them.

Wireframe of the results page, with no filter ticked yet. At the top, a summary bar reads DEL to LON, 20 Dec to 4 Jan, 2 adults, 1 child, with an Edit button. Under it, a progress line is about two thirds full, labelled 31 of 48 sources. On the left, a filter rail lists Stops with Nonstop (14) and 1 stop (212), Airlines with Emirates (37) and Qatar (28), and a price histogram with a slider under it. On the right, three sort tabs: Best, selected, Cheapest 412 euros, and Fastest 9 h 45 m. Below them, a pill reads 12 new results. Then three result cards with airline, local times, stops, and price.results: DEL to LONDEL to LON, 20 Dec to 4 Jan, 2 adults, 1 child Edit31 of 48 sourcesStopsNonstop (14)1 stop (212)AirlinesEmirates (37)Qatar (28)PriceBestCheapest €412Fastest 9h 45m12 new resultsAir India 02:30 to 06:45 nonstop €468Emirates 04:10 to 13:20 1 stop €412Qatar 03:45 to 12:05 1 stop €431
The summary bar is the URL in short form, the progress line counts sources rather than seconds, and the new results pill holds later arrivals so the cards below it stay where they are.

The wireframe above is the main screen we are designing toward. Keep it in mind: the search summary bar at the top is a compact copy of the search form, the progress line under it tracks providers, the filter rail on the left shows counts, and the list in the middle holds a sort tab bar and a pill that offers new results instead of inserting them.

Think first

If you could only solve one of these traits really well in a 45 minute interview, which would you pick, and why?

The search form: airports, trip types, dates, passengers, and a URL

The search form has five parts: where from and to, the trip type, dates, passengers, and cabin. Each part looks small, but each has one rule that a naive build gets wrong. And the whole form has one big decision: where does the search live? We answer that first, because it shapes everything else.

Decision Where should the current search (route, dates, passengers, cabin) live?

Component state in the form

The form holds the fields in useState, and submitting calls the search API and passes the result to the list.

Pros

  • Simplest to write.
  • No routing work.

Cons

  • A reload loses the search.
  • The traveller cannot share or bookmark it, and the back button does nothing useful.
  • The results page cannot be server-rendered for a link.

A global client store

A store such as Zustand or Redux holds the search, and both the form and the results read it.

Pros

  • Any component can read it.
  • Survives navigation inside the app.

Cons

  • Still lost on reload unless persisted.
  • Still not shareable.
  • Two sources of truth once you also put it in the URL for sharing.

The URL is the source of truth

Submitting navigates to a URL that encodes the whole search, for example /flights/DEL-LON/2026-12-20/2027-01-04?pax=2a1c&cabin=economy.

The results page parses the URL into a typed search object and starts the search from it. The form is filled from the same object.

Pros

  • Reload, share, bookmark, and back all work for free.
  • The server can render a results shell or a landing page from the same URL.
  • One parser means the form, the results page, and recent searches agree.

Cons

  • You must validate and normalize every URL, because people edit and paste them.
  • The URL has a length budget, so multi-city needs a compact format.

What we pick: The URL wins. A flight search is something people send to a partner or reopen tomorrow, so it must survive a reload and a share. The form edits a draft in component state; only submit writes the URL, and the URL starts the search. This is the url-as-state lesson applied to a whole page, and it matches the airbnb guide, where the search and filters also live in the URL.

The URL as the source of truth. At the top, the URL /flights/DEL-LON/2026-12-20/2027-01-04?pax=2a1c&cabin=economy goes into parseSearch, which validates and normalizes it. parseSearch feeds three consumers: the search form, which fills its draft; the search session, which sends POST /searches; and recent searches, saved in localStorage. An arrow from the search form back up to the URL is labelled submit, navigate./flights/DEL-LON/2026-12-20/2027-01-04?pax=2a1c&cabin=economyparseSearchvalidate, normalizeSearch formfills the draftSearch sessionPOST /searchesRecent searcheslocalStoragesubmit
The form edits a draft and only writes the URL on submit. Everything else reads the URL through parseSearch, so a reload, a shared link, and a recent search all start the same search.

Airport autocomplete with IATA codes and city groups

Airports have 3-letter IATA codes (DEL for Delhi, LHR for London Heathrow). Some cities have several airports, and IATA also defines metropolitan area codes that group them: LON covers Heathrow, Gatwick, Stansted, Luton, City, and Southend, and NYC covers JFK, Newark, and LaGuardia. A traveller who types London usually means any London airport, so the suggestion list shows the city group first with its airports indented below it.

  • Match on code, city, airport name, and country, and on common spellings: typing bom finds Mumbai, typing bombay finds it too.
  • Rank an exact code match first. Someone who types LHR wants Heathrow, not a city with LHR in its name.
  • The list of about 4,000 airports with scheduled flights is small enough to ship once as a compressed JSON file of roughly 100 to 200 KB, cache it, and search it in the browser. That avoids a request per keystroke. Load it lazily when the field gets focus.
  • The field stores a code and a type (airport or city), not the text. The text is only a label.

The keyboard behaviour, the combobox ARIA pattern, and the race between keystrokes are exactly what the autocomplete guide covers, so reuse that component and only plug in the airport data source and the grouped rendering.

Trip types, dates, and a price calendar

Model the trip as a list of legs, not as origin, destination, and two dates. One way is one leg, round trip is two legs where the second is the first reversed, and multi-city is up to 5 legs. With one model, the URL format, the API call, and the results code are all the same, and switching trip type is just adding or removing legs.

The date field is a range picker for round trips and a single picker for one way. Reuse the date-picker guide's component for keyboard support and ranges.

Flight sites add one thing: a price calendar, the cheapest known price under each day. Those prices come from a separate cached endpoint (for example GET /fare-calendar?route=DEL-LON&month=2026-12), are colour coded cheap to expensive, and are labelled as estimates. Fetch them only after the route is chosen and only for the visible months.

Passengers and cabin

  • Adults (12 and over), children (2 to 11), and infants (under 2). Typical limits: 9 passengers in total and at most one lap infant per adult, so the infant stepper disables itself when infants equal adults.
  • Cabin: economy, premium economy, business, first. It applies to the whole search.
  • The passenger mix is part of the price: fares are per passenger type, so changing it means a new search, not a filter.

Check your understanding · Exercise 2

A traveller pastes /flights/LON-NYC/2026-12-20?pax=3a4i into the address bar. What should the results page do? Select all that apply.

The results list: sorting, filters with counts, and thousands of rows

Once results are in, the traveller spends minutes here: sorting by cheapest, hiding overnight layovers, keeping only two airlines, dragging a price slider. Every one of those actions must feel instant, and each filter value should show how many results it would leave, like Nonstop (14) or Emirates (37). The first question is where that work runs.

Decision Where should sorting, filtering, and filter counts run for a set of up to 5,000 itineraries?

On the server, per change

Each filter change calls GET /searches/{id}/results?stops=0&airlines=EK,LH&sort=price&page=1 and the server returns one page plus counts.

Pros

  • Works for any result size.
  • The client stays thin.

Cons

  • Every tap waits a round trip of 100 to 400 ms on mobile.
  • Counts and pages race each other while the search is still streaming.
  • The server re-does work the client could do in a few milliseconds.

In the browser, on the main thread

Keep all itineraries in memory, run the filter predicates and the sort on every change, and compute counts in the same pass.

Pros

  • Instant: 5,000 rows with 8 predicates is tens of thousands of cheap checks, a few milliseconds on a mid-range phone.
  • Works offline and while streaming, with no races.

Cons

  • Memory for the whole set, roughly 2 to 10 MB of objects.
  • On a low-end phone a big recompute could become a long task over 50 ms.

In the browser, in a Web Worker

The worker holds the result store, receives the stream and the filter state, and posts back the ordered list of keys and the counts.

Pros

  • The main thread never blocks, even for 20,000 results.
  • Merging the stream also leaves the main thread.

Cons

  • More code: message passing, a copy of results for rendering.
  • Each round trip to the worker adds a little latency, which matters little here.

What we pick: Filter in the browser, on the main thread first, behind a small interface (applyFilters(store, filters) that returns keys and counts) so it can move into a worker if profiling on a low-end phone shows long tasks. A few thousand rows is the size where client-side work wins clearly, and the data-table guide draws its line between client and server mode at around 5,000 rows for the same reason. If a route can return 50,000 results, ask the server to pre-trim to the best few thousand.

Sorting: best, cheapest, fastest

Cheapest and fastest are plain sorts by price and total duration, with a stable tiebreak (departure time, then key) so equal rows never swap places between renders. Best is a score. A simple version: normalize price and duration to a 0 to 1 range within this search, add a penalty per stop and for overnight layovers, and sort by the weighted sum. The weights usually come from the server with the results, so the ranking team can tune them without a release.

Show the cheapest price and the fastest duration in the sort tabs themselves, as Kayak does on its Best, Cheapest, and Quickest tabs. The traveller then sees the trade-off before switching tabs.

Filters with counts per value

The count next to a filter value must answer one question: how many results have this value, given everything else I picked? So the count for a value in one group is computed with all the other groups applied, but not its own, which is how faceted search usually works. Otherwise ticking Nonstop would show 0 next to 1 stop, and the traveller could not see what adding a second option brings back. Values with a count of 0 stay visible but disabled, so the rail does not jump around.

ts
type Group = "stops" | "airlines" | "departTime" | "price";

function applyFilters(items: Itinerary[], f: Filters) {
  const passes = (it: Itinerary, skip?: Group) =>
    (skip === "stops" || f.stops.size === 0 || f.stops.has(it.stops)) &&
    (skip === "airlines" || f.airlines.size === 0 || it.carriers.some((c) => f.airlines.has(c))) &&
    (skip === "departTime" || inRange(it.departMinutes, f.departTime)) &&
    (skip === "price" || inRange(it.bestPrice, f.price));

  const shown = items.filter((it) => passes(it));
  const counts = { stops: new Map(), airlines: new Map() } as Counts;
  for (const it of items) {
    if (passes(it, "stops")) bump(counts.stops, it.stops);
    if (passes(it, "airlines")) it.carriers.forEach((c) => bump(counts.airlines, c));
  }
  return { shown, counts };
}
Counts for each filter group skip that group's own predicate. One pass over the results per group is cheap at this size.
Filter counts with Nonstop ticked. The stops counts skip the stops filter: Nonstop 14, ticked, 1 stop 212, and 2 or more stops 61. The airline counts apply the stops filter: Air India 9, British Airways 5, and Emirates 0, shown disabled. The shown list has 14 itineraries, nonstop only.stops countsairline countsshown listNonstop 141 stop 2122+ stops 61Air India 9British Airways 5Emirates 014 itinerariesnonstop onlycount = matches for this value, other groups applied
A count answers: how many results have this value, given every other filter I picked? So the stops counts ignore the stops filter, which is why 1 stop still says 212 and ticking it would add those 212 rows, while the airline counts already reflect Nonstop.

Filter state goes in the URL as query parameters (?stops=0,1&airlines=EK,LH&sort=price), next to the search. Use history.replaceState for filter changes, not a new history entry per tick, so the back button returns to the previous search rather than undoing one checkbox at a time. That is the same choice the airbnb guide makes for its filters.

A histogram price slider

A price slider alone hides the shape of the data: is most of it between 400 and 500 euros, or spread out? A small histogram above the slider (20 to 30 bars, each the count of results in a price bucket) shows where the mass is, so the traveller drags to a sensible spot. Bars inside the selected range are drawn solid, outside ones faded.

Build buckets from the result set after the other filters, so the histogram also answers the what-if question. The two thumbs are two elements with role="slider" (or two native range inputs), each with aria-valuetext such as 420 euros, and arrow keys move by one bucket.

Rendering thousands of rows

A result card is about 15 to 25 DOM nodes with logos and times. Rendering 3,000 of them is 45,000 to 75,000 nodes: slow first render, slow filtering, and heavy memory on a phone.

Two answers work. Show the first 50 sorted results and a Show more button, which is what many flight sites do and is accessible by default. Or virtualize the list: render only the rows near the viewport plus a few screens of overscan, as the virtualization lesson explains.

Cards have variable height (a mixed-carrier itinerary has an extra line), so measure them after render. We pick Show more for mobile and virtualization on desktop where people scroll long lists, with the same filter output feeding both.

Check your understanding · Exercise 4

The traveller ticks Nonstop. What should the airline counts and the stops counts show next? Select all that apply.

The itinerary detail: segments, layovers, local times, and fare families

Tapping a result opens its detail: each flight segment with times, airports, airline, flight number, and aircraft; each layover with its length; the baggage rules; and the fare families on offer. This is where travellers catch the problems that ruin a trip: a 45 minute connection, a change from Heathrow to Gatwick, a layover that runs overnight, or an arrival the next day. The detail must make those impossible to miss.

Most of it is already in the result store, so the detail opens instantly from memory. Only the fare families and exact baggage may need a call (GET /itineraries/{key}/fares), which we can prefetch when a card is hovered or focused for 200 ms.

Time zones: always show the airport's local time

A boarding pass, the airport screens, and the airline all use local time at each airport. So the site must too, whatever time zone the traveller's laptop is in. A traveller in New York planning a Delhi to London trip wants to see 02:30 in Delhi, not 16:00 New York time. The naive bug is to send UTC timestamps and format them with new Date(...).toLocaleTimeString(), which silently uses the browser's zone.

Decision How should the API send flight times so the client can show them correctly?

UTC instants only

"depart": "2026-12-20T21:00:00Z". The client formats them.

Pros

  • Durations are simple subtraction.
  • No ambiguity about the instant.

Cons

  • The client needs each airport's time zone to show local time, and formatting with the browser zone is an easy, silent bug.

Local wall time without offset

"depart": "2026-12-21T02:30", the time printed on the ticket.

Pros

  • Exactly what to display.

Cons

  • Durations and connection times are wrong across zones, and the client cannot tell which day it is elsewhere.

Local time with offset, plus the airport's zone

"depart": "2026-12-21T02:30:00+05:30" and "departZone": "Asia/Kolkata". Show the local part; compute durations from the instant.

Pros

  • Display and arithmetic are both correct.
  • The IANA zone name allows formatting with Intl.DateTimeFormat and its timeZone option.

Cons

  • A slightly larger payload.
  • The team must agree never to format these with the browser's zone.

What we pick: Local time with its offset, plus the IANA zone of the airport. The display reads the local part, durations use the instant, and day-change badges compare local dates. The server also sends the total duration so the client never has to compute the headline number itself.

ts
const fmtTime = (iso: string, zone: string, locale: string) =>
  new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", timeZone: zone })
    .format(new Date(iso));

const localDate = (iso: string) => iso.slice(0, 10); // "2026-12-21" from the local ISO string

function dayShift(departIso: string, arriveIso: string) {
  const ms = Date.parse(localDate(arriveIso)) - Date.parse(localDate(departIso));
  return Math.round(ms / 86_400_000); // +1, +2, or -1 (flying east across the date line)
}
Format in the airport's zone, and compute the day-change badge from local dates, not from 24 hour arithmetic.

The date line makes this concrete. A flight that leaves Tokyo at 00:30 on 21 December lands in Honolulu at 12:30 on 20 December, the day before, because Honolulu is 19 hours behind Tokyo. The card must say -1 day next to the arrival, or the traveller books a hotel for the wrong night. The other way, a late evening flight from Los Angeles lands in Sydney two calendar days later and shows +2.

An example itinerary from Delhi to London through Doha, in local times. It leaves DEL at 20:00 on 20 December, UTC plus 5:30, flies QR 579 for 4 hours 15 minutes, and lands in DOH at 21:45 on 20 December, UTC plus 3. A layover of 9 hours 20 minutes runs overnight in Doha. It leaves DOH at 07:05 on 21 December, flies QR 7 for 7 hours 30 minutes, and lands in LHR at 11:35 on 21 December, marked plus 1 day. The total trip is 21 hours 5 minutes, computed from instants: 14:30 UTC to 11:35 UTC the next day.9 h 20 m overnight in DohaDEL 20:0020 Dec +05:30DOH 21:4520 Dec +03:00DOH 07:0521 Dec +03:00LHR 11:3521 Dec, +1 dayQR 5794 h 15 mQR 77 h 30 mfrom instants: 14:30 to 11:35 UTC next day = 21 h 05 m
Every time is the airport's own local time. The +1 badge compares local dates, and the total comes from instants, not from subtracting 20:00 from 11:35.

Layovers worth warning about

  • Short connection: below the airport's minimum connection time plus a margin, flag it as tight.
  • Overnight layover: the layover crosses local midnight. Say so in words, with its length: 9 h 20 m overnight in Doha.
  • Airport change: arrive at Heathrow, depart from Gatwick. Show both codes in a warning colour and the words change airports.
  • Self-transfer: separate tickets that the site has combined. The traveller must collect and re-check bags and is not protected if the first flight is late. This needs the strongest warning and an explicit tick in the booking flow.
  • Operated by: a codeshare where the flight number is LH but the plane is United. Show who operates it, because that is the check-in desk.

Baggage and fare families

Airlines sell the same seat as several fare families with different rules. Show them as columns the traveller can compare, with the price difference rather than the total, so the jump from Basic to Standard reads as plus 38 euros. Use words and icons together, never icons alone, and say what is not included, not only what is.

An example fare family comparison. The rules come from the airline per fare; the numbers here are illustrative.
Fare familyCabin bagChecked bagSeat choiceChangesRefund
Basic1 small bag under the seatNot includedPaidNot allowedNo
Standard (plus 38 euros)1 bag up to 8 kg1 bag up to 23 kgFree standard seatsFee of 70 eurosNo
Flex (plus 145 euros)1 bag up to 8 kg2 bags up to 23 kgFree, including extra legroomFreeYes, minus a fee

Check your understanding · Exercise 5

A traveller in Berlin views a flight from Delhi at 02:30 local time to London at 06:45 local time the same day. Which statements are true? Select all that apply.

Price changes and freshness: the search price is a guess

The prices on the results page come from caches. A provider may have computed them minutes or hours ago, because asking an airline for a live price on every search would be slow and expensive. The industry tracks this as the look-to-book ratio: many searches for each booking, so searches are served from caches.

Fares reach agencies and metasearch sites through GDSs (global distribution systems such as Amadeus, Sabre, and Travelport) and through airlines' own NDC (New Distribution Capability) APIs. How long each source caches fares varies and is rarely public. So when the traveller selects a flight, we ask for the real price of that one fare, and the frontend's job is to be honest about the difference.

The naive build carries the search price into checkout and charges whatever the airline says at the end. The failure is concrete: the traveller sees 412 euros, types four passport names for ten minutes, pays, and gets an email saying 468 euros. That is a support ticket, a chargeback risk, and in the EU a legal problem. EU Regulation 1008/2008 requires the final price, including unavoidable taxes and fees, to be shown from the first display, and optional extras such as seats and bags to be opt-in.

Decision When should the client confirm the real price of a fare?

Only at payment

Carry the search price through the flow and let the booking call return the real price.

Pros

  • One fewer request.
  • Fast to build.

Cons

  • The price change appears after the traveller did all the work, often after the card was charged.
  • Fails the honest price requirement.

On select, then trust it

Call POST /offers/{offerId}/price when a fare is chosen. Show the confirmed price for the rest of the flow.

Pros

  • The traveller sees the real price before typing anything.
  • One extra request at a natural pause.

Cons

  • The priced quote can expire during a long flow, and the booking can still fail on price.

On select, with expiry, and re-checked at booking

Pricing returns a pricedOfferId, a total, and an expiresAt. The flow shows a countdown. The booking call sends the pricedOfferId and the total the traveller saw. If the server's price differs, it refuses with a PRICE_CHANGED error that carries the new price.

Pros

  • The price shown on the payment step is the price charged, or nothing is charged.
  • Expiry is visible, not a surprise.

Cons

  • More states to design: expired, changed up, changed down, sold out.

What we pick: Price on select, carry an expiring quote, and make the booking call conditional on the price the traveller saw. This is the same quote-with-expiry idea the uber guide uses for upfront fares: the client never charges a number it has not shown. Showing the full price including taxes from the results page on also avoids most surprises in the first place.

Sequence diagram of selecting a fare, between the traveller, the client, the BFF, and the airline. The traveller taps Select. The client asks the BFF to price offer o_91. The BFF makes a live fare check with the airline, which answers 468 euros, up from 412. The BFF replies with the priced offer at 468 euros, expiring in 15 minutes. The client shows a dialog saying the price went up. The traveller taps Continue at 468 euros. The client creates the booking draft, and the BFF replies with a hold until 15 minutes from now.TravellerClientBFFAirlinetaps Selectprice offer o_91live fare check€468, was €412€468, 15 minprice went upContinue, €468create draftholdUntil +15 min
The price check happens before any typing, so a change costs the traveller one tap, not ten minutes. A higher price always stops for a decision; a lower one is shown without a dialog.

Showing a price change honestly

  • Up: stop and show a dialog. The old price struck through, the new price, the difference per passenger and in total, and two buttons: Continue at 468 euros, or Back to results. Never accept a higher price silently, even by one euro.
  • Down: just show the new, lower price with a short note. No dialog needed, but do not hide that it changed.
  • Sold out: say the fare is no longer available, offer the next fare family on the same flights if there is one, and keep the results list where the traveller left it.
  • Expired: when expiresAt passes, re-price automatically on the next action and show any change with the same rules.

On the results page, a stale price is a weaker problem but still worth care. If a search is older than about 15 to 30 minutes (the tab sat in the background), show a bar that says prices may have changed, with a Refresh button that starts a new search session from the same URL. Do not refresh by itself: the list would change under the traveller.

Check your understanding · Exercise 6

At payment, the booking call returns PRICE_CHANGED with a new total 9 euros higher. What should the client do? Select all that apply.

The booking flow: steps, saved progress, and a visible clock

After pricing, the traveller goes through passenger details, seats, extras, and payment. For a family of four this takes 10 to 15 minutes, on a phone, often with interruptions. Three things go wrong in naive builds: progress is lost on a reload or a back tap, the session or hold expires without warning, and passport names are entered in a way the airline later rejects.

Decision Where should the booking in progress live?

Client memory only

A store holds all steps; the payment step sends everything at once.

Pros

  • Simple, no extra endpoints.

Cons

  • A reload, a crash, or a 3-D Secure redirect loses everything.
  • Seats and holds that live on the server are not tied to anything the client can come back to.

Browser storage

Mirror the store into sessionStorage after each change and restore it on load.

Pros

  • Survives reloads and redirects in the same tab.
  • No backend work.

Cons

  • Passport numbers and birth dates sit in plain text in the browser.
  • Does not move to another device and drifts from the server's hold and seats.

A server-side booking draft

Continuing from the priced fare creates a draft (POST /bookings with the pricedOfferId) that returns a bookingId. Each step saves with PATCH /bookings/{id} when the traveller presses Continue. Every step is a route such as /book/{id}/passengers.

Pros

  • Reload, back, a redirect, or another device all resume from the server.
  • Holds, seats, and prices hang off one id.
  • Sensitive data is stored once, on the server, with proper protection.

Cons

  • One save request per step.
  • Needs a draft lifetime and cleanup on the server.

What we pick: A server-side booking draft, with each step as its own route. Unsaved typing within a step stays in form state, as the form-state lesson describes, and is saved on Continue. If the browser storage option is used at all, keep only non-sensitive fields such as the chosen step. The back button then works for free: going back to passengers loads the saved draft, and nothing is lost.

State machine of the booking flow. Pricing leads to passengers, then seats, then extras, then payment on Continue. Payment leads to submitting on Pay. Submitting leads to confirmed on a 200 response, to failed when the card is declined, and to price changed on a PRICE_CHANGED error. Price changed returns to payment when the traveller accepts. Expired can be reached from any step with a hold by the HOLD_EXPIRED event, and it re-prices.pricingpassengersseatsextrasContinuepaymentsubmittingconfirmedPay200price changedaccept: to paymentfailedexpiredre-pricePRICE_CHANGEDdeclinedHOLD_EXPIRED, any step
The happy path is a straight line. Events such as HOLD_EXPIRED and PRICE_CHANGED can arrive from several states, and each has one defined target, so no screen ever shows a payment form for a quote that is gone.

The flow is a state machine, as the state-machines lesson describes: each step can only move to certain others, and a few events can arrive from anywhere: the hold expires, the price changes, or the fare sells out. Modelling those as events with explicit transitions stops the impossible screens, such as a payment form for a quote that already expired.

ts
type BookingState =
  | { step: "pricing" }
  | { step: "passengers" | "seats" | "extras"; bookingId: string; holdUntil: string }
  | { step: "payment"; bookingId: string; holdUntil: string; total: Money }
  | { step: "submitting"; bookingId: string; idempotencyKey: string }
  | { step: "priceChanged"; bookingId: string; oldTotal: Money; newTotal: Money }
  | { step: "expired"; bookingId: string }
  | { step: "confirmed"; pnr: string }
  | { step: "failed"; reason: "declined" | "soldOut" | "unknown" };
The booking states. Events like HOLD_EXPIRED and PRICE_CHANGED are valid from several states, and each has one defined target.

Passenger details: name as on passport

  • Separate fields for given names and surname, labelled exactly as on passport, with a one-line hint and an example. Do not use one full name field: airlines store surname and given names separately.
  • Accept what people type, including accents, apostrophes, and hyphens, then convert it to the plain letters A to Z that airline reservation systems store. The passport's machine-readable zone (the two lines of letters at the bottom of the photo page) shows the official Latin spelling, and its transliteration turns Ø into OE and Ä into AE, which a plain accent strip gets wrong. When the stored name differs from what was typed, show it (Søren becomes SOEREN) and ask the traveller to check it against that zone.
  • Do not over-validate. Allow very short and very long names, and support people with only one name: airlines differ (some put it in the surname field with FNU, first name unknown, as the given name; others repeat it), so offer an I have only one name option and let the backend apply each airline's rule.
  • No titles in the name field: offer Mr, Ms, and Mx as a separate choice where the airline needs it.
  • Check date of birth against the passenger type chosen in search. A child must be 2 to 11 on the travel date, and an infant must still be under 2 on the return flight, or that leg needs a child fare and its own seat.
  • Many international routes also need APIS (Advance Passenger Information) data, which the airline sends to border authorities before departure: full name, date of birth, gender, nationality, and the passport's number, issuing country, and expiry. Some airlines collect it at booking and others up to check-in, so ask for it only when the route needs it.
  • Validate on blur and on Continue, put the error next to the field in words, and move focus to the first invalid field. The input-validation lesson covers the rule that the server checks everything again.

Holds and session timeouts with a visible countdown

Two clocks run during booking. The fare hold is how long the priced offer and any held seats stay valid, set by the airline or our backend, often 10 to 30 minutes. The login session is how long the traveller stays signed in; if it times out, signing in again must return them to the same step with nothing lost, which the server draft makes easy.

Show the hold as a countdown in the step header: Price held for 14:32. Make it a quiet role="timer" with the visible text, and announce only key moments (5 minutes left, 1 minute left) through a polite live region, not every second.

WCAG 2.2 success criterion 2.2.1, Timing Adjustable (level A), applies here. Its usual route for a limit like this: warn before time runs out, give at least 20 seconds to extend it with a simple action, and allow at least ten extensions. So at 2 minutes left, show a dialog with Extend, which calls POST /bookings/{id}/extend to re-price the offer and renew the hold; if the price changed, the price-changed rules apply.

An airline may cap how often a hold can be extended. Do not lean on WCAG's exceptions for that: when the cap is reached, say so and offer Re-price, which gets a fresh quote and a new hold with every entered detail kept, so the traveller can always continue.

Base the countdown on the server's holdUntil and a clock offset measured from the response Date header (or a server time field), not on the device clock, which can be minutes wrong. Start the ticking in an effect on the client: a countdown computed from Date.now() during server rendering would not match the browser's first render.

Check your understanding · Exercise 7

A traveller is on the seats step with 6 minutes left on the hold. They press the browser back button, then reload the page. What should a good design guarantee? Select all that apply.

Seat selection: a map that works with a mouse, a thumb, and a keyboard

The seat map shows the cabin from above: rows numbered from the front, seat letters across (A to F on a narrow-body plane, often A to K on a wide-body), aisles, exits, wings, and toilets. Each seat is available, taken, or blocked, and has a price and features such as window, aisle, or extra legroom. The traveller picks one seat per passenger. The map comes from the airline per flight segment, so a two-flight trip has two maps.

Decision How should the seat map be drawn?

One SVG with clickable shapes

Draw the fuselage and every seat as SVG shapes; attach click handlers to seat shapes.

Pros

  • Pixel-perfect cabin drawing that scales.
  • Easy to match a designer's art.

Cons

  • SVG shapes are not buttons: focus, roles, names, and keys all have to be added by hand.
  • Text and touch targets scale with the drawing, often too small on phones.

A CSS grid of real buttons

Each row is a grid row and each seat a button with an accessible name. Aisles are empty grid columns. Decoration such as wings and exits is a background SVG with aria-hidden.

Pros

  • Focus, activation with Enter and Space, and screen reader names come with the button.
  • Touch targets can be 44 by 44 CSS pixels even when the drawing is small.

Cons

  • Odd layouts (a bulkhead row with fewer seats, a staggered business cabin) need care in the grid.

Canvas

Paint the map into a canvas and hit-test clicks.

Pros

  • Fast for tens of thousands of seats, which is a stadium, not a plane.

Cons

  • Invisible to assistive technology without a full parallel DOM. Far too much cost for about 180 to 400 seats.

What we pick: A CSS grid of real buttons, with SVG only for decoration. A plane has a few hundred seats at most, so rendering cost is no reason to give up native buttons. Stadium-scale maps are a different problem, covered by the planned Design a seat selection map for events problem.

The seat map data is small and specific: per segment, a list of cabin decks, rows, and seats, each seat with its letter, status, price, and characteristics such as window, aisle, exit row, or no recline. Airlines describe these with codes, and the BFF turns them into plain fields. Fetch the map only when the seats step opens, one request per segment in parallel, and show the first segment's map while the others load. A trip with four segments should never block on the slowest map.

Wireframe of the seat map for the flight DEL to DOH. At the top, a passenger switcher with Anna selected, then Ben and Chloe. Below, seat columns A, B, C, an aisle, then D, E, F, and rows 12 to 16. Taken seats show an x. Row 14 is an exit row with extra legroom, drawn hatched, and seat 14A is selected for Anna. At the bottom, two buttons: Choose from a list, and Seat us together. Notes on the right say each seat is a button, the map is one tab stop with arrow keys inside, and a seat taken by someone else returns 409, refreshes the map, and is announced.Seats: DEL to DOHAnnaBenChloeABCDEF12xxx13x1414Axx15xxxx16exitChoose from a listSeat us togethereach seat: a buttonRow 14, seat A,window, €18one tab stop,arrow keys inside409 if taken:refresh, announce
Seats are real buttons laid out in a CSS grid, so focus, names, and activation come for free. The list view uses the same data for anyone who finds the map hard to use.

Keyboard and screen readers

  • Give the map grid semantics, as in the WAI-ARIA grid pattern: a container with role="grid" and a name such as Seat map, DEL to DOH, a role="row" per seat row, and each seat button inside a role="gridcell". Screen readers then announce a two-dimensional widget and pass arrow keys to it.
  • Treat the map as one tab stop with a roving tabindex: the focused seat has tabIndex={0}, all others -1. Arrow keys move between seats, skipping aisles; Home and End go to the row's ends. Tab leaves the map.
  • Give every seat a full name: Row 14, seat A, window, extra legroom, 18 euros, available. Taken seats are still focusable but marked aria-disabled="true" so a screen reader user can learn the layout.
  • Do not rely on colour. Use a shape or a letter for taken and selected seats, and a legend.
  • Offer a list alternative: a Choose from a list button that shows available seats as a filterable list (window, aisle, together, price). Many sighted people on small phones prefer it too.

Availability changes while you choose

Other people are choosing seats on the same flight, so the map is only a snapshot with a version. Choosing a seat calls PUT /bookings/{id}/seats with the segment, passenger, and seat. If someone else took it a moment ago, the server answers 409 Conflict with the fresh map. The client then marks that seat taken, keeps focus on it, and announces Seat 14A was just taken, please choose another.

Optimistic selection is fine visually, but the price total updates only after the server confirms, because seats cost money. For a few hundred seats, re-fetching the map when the step opens and after each conflict is enough; a live stream of seat changes is not worth it here.

For several passengers, show a passenger switcher above the map (Anna, Ben, Chloe) and move to the next passenger after each pick. Offer Seat us together, which asks the server for the best adjacent block. And keep the rule that infants on laps cannot sit in exit rows: the server marks those seats as not allowed for that passenger, and the name says why.

Check your understanding · Exercise 8

Which choices make the seat map usable with a keyboard and a screen reader? Select all that apply.

Payment, 3-D Secure, and a booking that never happens twice

The payment step looks like any checkout, and most of it should be: the amazon guide's checkout covers card fields in the payment provider's iframes (so card numbers never touch our servers or our JavaScript), wallets such as Apple Pay and Google Pay, and saved cards. Flights add two twists: the purchase is slow and has several stages, and a lost response can leave the traveller unsure whether they own a ticket.

3-D Secure without losing the booking

In the EU and the UK (strong customer authentication rules) and in India (the Reserve Bank's additional factor of authentication), many card payments need 3-D Secure: the cardholder's bank may ask them to approve in a banking app or with a one-time code. The amazon guide covers how 3-D Secure works, its frictionless and challenge flows, and how a checkout survives the bank's redirect. Only the flight-specific parts are new here.

Because the booking draft lives on the server with its bookingId in the URL, a redirect to the bank and back costs nothing: the return URL is /book/{id}/payment/return, which reads the draft and the payment result and continues. The hold keeps running during the challenge, so keep the countdown visible and extend the hold before starting it when less than 3 minutes are left.

Never double-book

The classic failure: the traveller presses Pay, the request reaches the server, the airline issues the ticket, the card is charged, and then the response is lost because the train entered a tunnel. The client sees a timeout. If it shows Payment failed, try again, the traveller presses Pay once more and gets a second ticket and a second charge. A disabled button only stops the double click; it does nothing for this case.

Decision How do we make sure one Pay press creates at most one booking, even with retries and lost responses?

Disable the button while submitting

Set disabled on Pay until the request settles.

Pros

  • Stops double clicks.
  • One line of code.

Cons

  • Does nothing for a lost response, a reload, or a retry. The second attempt is a new request the server cannot tell apart.

Server dedupes by booking id

The server refuses a second purchase for a bookingId that already has one.

Pros

  • No client work.
  • Catches retries of the same draft.

Cons

  • Cannot tell a retry of the same attempt from a deliberate second attempt after a decline, for example with another card.

An idempotency key per attempt

When the traveller presses Pay, generate crypto.randomUUID() and send it as an Idempotency-Key header. Every retry of that attempt reuses the key. The server stores the key with the result and replays the stored result for a repeat.

Pros

  • Retries are safe at any point: lost responses, reloads, and timeouts all return the original outcome.
  • A new attempt after a decline gets a new key, so it is allowed.
  • The same pattern payment providers such as Stripe use on their own APIs.

Cons

  • The client must store the key with the attempt (in the draft or sessionStorage) so a reload reuses it.
  • The server must keep keys for at least as long as retries can happen; Stripe, for example, keeps them for at least 24 hours.

What we pick: An idempotency key per payment attempt, sent on every retry, plus the disabled button for the double click, plus the server's booking-id guard as a backstop. The key is what makes the lost-response case safe, and the uber guide uses the same idea for ride requests. The retries-and-backoff lesson explains when retrying is safe at all; with a key, it is.

Sequence diagram of a purchase with a lost response, between the client, the BFF, the payment provider, and the airline. The client sends the purchase with idempotency key 6c1e. The BFF asks the payment provider to authorize 824 euros, and it is authorized. The BFF asks the airline to issue the ticket, and the airline returns PNR X7K2QD. The BFF's response to the client is lost. The client retries with the same key 6c1e. The BFF finds the key and replays the stored result, PNR X7K2QD, without charging or ticketing again.ClientBFFPaymentsAirlinepurchase, key 6c1eauthorize €824authorizedissue ticketPNR X7K2QDresponse lostretry, key 6c1estored: X7K2QD
The second request does no work: the BFF finds 6c1e in its key store and returns the first result. A new key here would have bought a second ticket.
ts
async function submitBooking(bookingId: string, attempt: PayAttempt) {
  const key = attempt.key ?? (attempt.key = crypto.randomUUID());
  saveAttempt(bookingId, attempt); // survives a reload or a 3-D Secure redirect
  for (let i = 0; i < 4; i++) {
    try {
      const res = await fetch(`/api/bookings/${bookingId}/purchase`, {
        method: "POST",
        headers: { "Idempotency-Key": key, "Content-Type": "application/json" },
        body: JSON.stringify({ total: attempt.total, payment: attempt.paymentToken }),
        signal: AbortSignal.timeout(20_000),
      });
      if (res.status !== 409 && res.status < 500) return await res.json(); // confirmed, declined, or price changed
    } catch {
      /* network error or timeout: the outcome is unknown, so retry the same key */
    }
    await sleep(1000 * 2 ** i + Math.random() * 500);
  }
  return { status: "unknown" as const }; // show "We are checking your booking", poll GET /bookings/{id}
}
Submitting with one key per attempt. A timeout leads to retries with the same key, never to a new attempt.

In this sketch a 409 means the server is still processing the first request with this key, the code the IETF Idempotency-Key draft suggests, and a 5xx means the outcome is unclear. In both cases the client waits and asks again with the same key.

If all retries fail, the outcome is unknown, and the screen must say so, for example: We are confirming your booking, please do not pay again, and we will email you within 10 minutes. Then poll GET /bookings/{id} every few seconds. Ticketing itself can take a while after payment, so even a successful response may say ticketing in progress, and the confirmation page should update when the e-ticket number arrives.

Booking session. Phase: results. Price 412 euros. Hold 15:00 left, 0 of 2 extensions used. Idempotency key: none yet. Server bookings: 0. Server log: empty.price: €412hold: 15:00extended: 0 of 2key: none yetphase: resultsbookings: 0charges: 0server log
Select the 412 euro fare to price it.

Check your understanding · Exercise 9

The purchase request times out after 20 seconds. Which client behaviours are safe? Select all that apply.

Architecture: the pieces and where their boundaries are

With the hard parts decided, we can name the pieces. The rule for every boundary: put each clock and each source of truth in exactly one place. The URL owns the search, the search session owns the stream, the result store owns merged itineraries, and the server's booking draft owns the booking.

Architecture of the flight booking frontend. Inside the browser, the routes (landing, results, and booking) hold the search form, results list, filters, booking steps, and seat map. The routes use three services in a row: the URL parser, the filter engine, which returns keys and counts, and the booking client, which handles the draft and the payment key. Under each service sits its store: the URL parser saves recent searches and alert ids to localStorage, the filter engine reads the result store, which merges and holds pending results, and the booking client caches the draft and seat maps in the query cache. At the bottom, the payment SDK runs in an iframe with 3-D Secure, and the SSE client streams batches into the result store. Outside the browser, the BFF runs search sessions and pricing and calls the providers, airlines and agencies, above it. The booking client calls the BFF over HTTP, and the SSE client receives the result stream from it.BrowserRoutes: landing, results, bookingSearchForm, ResultsList, Filters, Steps, SeatMapURL parserFilter engineBookingdraft, pay keylocalStoragerecent, alert idsResult storemerge, pendingQuery cachedraft, seatsPayment SDKiframe, 3DSSSE clientpoll fallbackProvidersairlines, agenciesBFFsessions, pricing
Each box owns one thing: the URL parser owns the search, the result store owns merged results, the booking client owns money paths. The browser only ever talks to our BFF, never to providers.
PieceResponsibilityWhy it is separate
Route pagesLanding pages (server rendered), search results, and booking steps as routes.Each needs a different rendering strategy and code bundle.
Search params parserURL to a typed search and filter object, and back. Validates and normalizes.One parser keeps the form, results, recent searches, and server rendering in agreement.
Search session clientStarts a session, opens the SSE stream, falls back to polling, drops stale events, reports progress.Hides the transport, so the rest of the app sees only batches and progress.
Result storeMerged itineraries keyed by flights, offers per seller, the shown list and the pending set.The stable-order logic lives in one place and can move into a worker.
Filter enginePure function from results and filters to ordered keys and counts.Pure and testable; no UI or network inside.
Booking clientPricing, the draft, holds, seats, and the purchase with idempotency keys.Money paths get the strictest error handling and tests.
Server state cacheTanStack Query or similar for draft, fare calendar, and seat maps.Caching, refetch on focus, and retries without hand-written code.

Behind the browser sits a backend for frontend (BFF): a thin server our team owns. It turns provider formats into one itinerary shape, runs the search session fan-out, and renders the landing pages. The browser never talks to providers directly, because providers need secret keys, return wildly different formats, and must be rate limited.

Data flow for search and booking. Top row, outbound: the URL with filters is parsed by the search client, which sends POST /searches to the BFF session, which fans out to the providers, and providers answer back to the BFF. Middle row, inbound: the BFF pushes SSE batches down to the result store, which hands itineraries to the filter engine, which also reads filters from the URL and gives keys and counts to the results list. Bottom row, writes: selecting a result goes to the booking client, which sends price and purchase calls to the BFF booking service, which checks with the airline.parsePOST /searchesfan outURLsearch, filtersSearch clientBFF sessionProvidersSSE batchesfiltersResults listFilter engineResult storeitinerarieskeys, countsSelectBooking clientBFF bookingAirlineprice, purchase
Reads flow right to left along the middle row and are cheap to repeat; writes along the bottom row carry money and use quotes and idempotency keys. Only the URL and the server hold anything that must survive a reload.

Follow one search through the data flow. The URL is parsed and sent as POST /searches, and the BFF fans out to providers and writes their answers into the session. Batches stream back over SSE into the result store, which merges them, and the filter engine reads the store and the URL's filters and returns the keys and counts the list renders.

Selecting a fare goes the other way: a pricing call returns a quote, continuing creates the draft, and from then on the booking client and the server draft drive the screens.

Check your understanding · Exercise 10

Why does the browser call our BFF instead of calling airline and agency APIs directly? Select all that apply.

Data model: what the client holds and where

The architecture named one owner per source of truth. This chapter adds what each piece of data looks like, where it lives, and for how long. Mixing homes is where bugs come from: a filter kept in a store and in the URL drifts, and a passport number in localStorage leaks.

DataWhere it livesLifetime
Search and filtersURLAs long as the link exists.
Itineraries and offersResult store in memory (or a worker)One search session; dropped on a new search.
Priced offer, booking draft, seatsServer, mirrored in the server state cacheUntil the hold expires or the booking completes.
Form fields being typedComponent form stateUntil Continue saves them to the draft.
Recent searches, alert ids, currencylocalStorageAcross visits, capped at 10 searches.
ts
type Money = { amountMinor: number; currency: string }; // 41200 + "EUR"
type Search = { legs: { from: Place; to: Place; date: string }[]; pax: { adults: number; children: number; infants: number }; cabin: Cabin };
type Place = { code: string; kind: "airport" | "city" };

type Segment = {
  carrier: string; number: string; operatedBy?: string;
  from: string; to: string; depart: string; arrive: string; // local ISO with offset
  departZone: string; arriveZone: string; aircraft?: string;
};
type Offer = { sellerId: string; price: Money; fareFamily?: string; bookOnSite: boolean };
type Itinerary = {
  key: string; segments: Segment[]; durationMin: number; stops: number;
  offers: Map<string, Offer>; bestPrice: number; flags: ("overnight" | "airportChange" | "selfTransfer")[];
};
type ResultStore = { searchId: string; byKey: Map<string, Itinerary>; shown: string[]; pending: Set<string>; version: number };
type PricedOffer = { pricedOfferId: string; total: Money; expiresAt: string };
The core client types. Money is in minor units (cents) with a currency code, never a float.
The client data model, grouped by where each entity lives. In the URL: Search, with legs, passengers, and cabin, and Filters. In memory, the result store: Itinerary, with key, stops, and best price, which has many Segments, with local times and zones, and many Offers, with seller and price. On the server, mirrored in the query cache: PricedOffer, with total and expiry, leading to the Booking draft, with hold time and seats, which has a pay attempt with its idempotency key. In localStorage: recent searches, the last 10 URLs, and alert ids, while the server owns the alerts.URLSearchlegs, pax, cabinFiltersMemory: result storeItinerarykey, stops, bestSegmentlocal time + zoneOfferseller, priceServer draft, in query cachePricedOffertotal, expiresAtBooking draftholdUntil, seatsPay attemptidempotency keylocalStorageRecent searcheslast 10 URLsAlert idsserver owns alerts
Four homes, four lifetimes. The URL lives as long as the link, memory as long as one search, the server draft as long as the hold, and localStorage across visits but never with anything sensitive.

Two details matter. Prices are integers in minor units with a currency, because floating point adds 0.1 and 0.2 into 0.30000000000000004 and money must add exactly. And shown holds keys, not copies: the row reads the latest itinerary from byKey, so a cheaper offer updates the price in place without touching the order.

Interface: the API between client and BFF

The API follows the flow: start a search, stream it, price an offer, edit a draft, buy it. Every error has a machine-readable code (such as PRICE_CHANGED or SEAT_TAKEN) so the client can pick the right screen without parsing messages.

http
POST /api/searches
{ "legs": [{ "from": "DEL", "to": "LON", "date": "2026-12-20" }], "pax": { "adults": 2 }, "cabin": "economy" }
-> 201 { "searchId": "s_8f2c", "providers": 48, "expiresAt": "2026-10-20T18:10:00Z" }

GET /api/searches/s_8f2c/events          (Accept: text/event-stream)
id: 7
event: batch
data: { "searchId": "s_8f2c", "itineraries": [ ... ], "done": 19, "total": 48 }

id: 31
event: complete
data: { "searchId": "s_8f2c", "done": 46, "total": 48, "timedOut": ["prov_x", "prov_y"] }

GET /api/searches/s_8f2c?after=7          (polling fallback, same payloads as JSON)
The search session and its stream. Each SSE event has an id for resume.
http
POST /api/offers/o_91/price
-> 200 { "pricedOfferId": "po_5", "total": { "amountMinor": 82400, "currency": "EUR" }, "expiresAt": "..." }

POST  /api/bookings           { "pricedOfferId": "po_5" }  -> 201 { "bookingId": "b_77", "holdUntil": "..." }
PATCH /api/bookings/b_77      { "passengers": [ ... ] }  -> 200 { "holdUntil": "...", "step": "seats" }
POST  /api/bookings/b_77/extend  -> 200 { "holdUntil": "...", "total": { ... } }
PUT   /api/bookings/b_77/seats { "segment": 0, "passenger": 1, "seat": "14A" }
-> 409 { "code": "SEAT_TAKEN", "seatMap": { ... } }

POST /api/bookings/b_77/purchase      Idempotency-Key: 6c1e...
{ "total": { "amountMinor": 82400, "currency": "EUR" }, "payment": "pm_tok_..." }
-> 200 { "status": "confirmed", "pnr": "X7K2QD", "ticketing": "pending" }
-> 422 { "code": "PRICE_CHANGED", "newTotal": { ... } }
-> 402 { "code": "CARD_DECLINED" }
Pricing, the draft, and the purchase. The purchase carries the total the traveller saw.

Check your understanding · Exercise 11

Why does the purchase request send the total, when the server already knows the priced offer? Select all that apply.

For components, the important contracts are small: SeatMap takes a seat map, the passenger being seated, and the chosen seats, and emits onSelect(seatId); ResultsList takes ordered keys, a lookup, and a pending count, and emits onApplyPending and onOpen(key). Keeping them data-in, events-out lets the seat map and list be tested without a network.

Performance, SEO landing pages, recent searches, and price alerts

Two kinds of visitors arrive: some come from a search engine to a page like Flights from Delhi to London, and others come to the home page or a shared results link and search. The first group needs a fast, crawlable page with real content. The second needs the first results on screen as soon as possible.

Decision How should route landing pages like flights from Delhi to London be rendered?

Client-side rendering

Ship an empty shell and fetch route data in the browser.

Pros

  • One code path with the app.

Cons

  • Crawlers may see an empty page, and LCP waits for JavaScript and an API call.

Server rendering on every request

Render each landing page on the server per visit with fresh cached prices.

Pros

  • Fresh content, crawlable.

Cons

  • Server cost for tens of thousands of route pages and every crawler visit, for data that changes hourly at most.

Static generation with periodic revalidation

Pre-render popular routes and render the rest on first request, then cache at the CDN and re-render every hour or so (incremental static regeneration).

Pros

  • CDN-fast HTML, good LCP, crawlable.
  • Prices like cheapest in the last 7 days are honest with a timestamp.

Cons

  • Content can be up to an hour old, so it must say from and when the price was seen.

What we pick: Static generation with hourly revalidation for landing pages, labelled with when prices were seen, and a search form prefilled with the route so one tap starts a live search. The live results page itself is not for search engines: it is rendered as a light shell, and the search runs in the browser.

Getting the first results fast

  • Start the search session during the page request. When the server renders the results shell for a URL, it also calls POST /searches and puts the searchId in the HTML, so the browser opens the stream at once and saves a full round trip. Skip this for crawlers and link prefetches, which would start costly searches nobody sees.
  • Keep the results route light: the list, filters, and the stream client, around 100 to 150 KB of compressed JavaScript. The booking flow, seat map, and payment SDK load as separate chunks, prefetched when the traveller hovers or focuses a Select button.
  • Load the airport data when the search field gets focus, not on page load, and cache it with a long Cache-Control max-age and a hashed file name.
  • Show skeleton cards with the exact final height, so the first batch does not shift layout and CLS stays near 0.
  • Cache the fare calendar and landing page data at the CDN for minutes; never cache a priced offer.

Recent searches and price alerts

Recent searches are just recent URLs. Store the last 10 parsed searches in localStorage with a timestamp, deduped by their canonical URL, and drop any whose departure date has passed. Show them under the search form; tapping one navigates to its URL. Nothing sensitive is stored, and private browsing simply starts empty.

A price alert must run while the traveller is away, so it lives on the server: POST /alerts with the route, dates or a flexible month, and an email or a push subscription. The server re-checks prices on a schedule and notifies on a drop. The browser keeps only the alert ids in localStorage to show a Tracking badge on that route, and asks for push permission only after the traveller turns an alert on, never on page load.

Check your understanding · Exercise 12

Which changes most directly shorten the time to the first visible result on the results page? Select all that apply.

Internationalization and accessibility

A flight site sells across borders by nature: a traveller in Dubai books Arabic pages, pays in dirhams, and flies to Paris. The locale-formatting lesson covers the Intl APIs; here are the places flights stress them.

  • Currency is a separate choice from language. An English speaker in India wants rupees. Format with Intl.NumberFormat(locale, { style: "currency", currency }). When a seller charges in another currency, say so: about 412 euros, charged as 448 US dollars, because card fees depend on it.
  • Times follow the locale's clock: 14:05 in Germany, 2:05 PM in the US. Intl.DateTimeFormat picks this from the locale; never hard-code a pattern. Dates in the URL stay ISO (2026-12-20), whatever the display.
  • Right-to-left layouts for Arabic and Hebrew: set dir="rtl" on the document and use logical CSS properties (margin-inline-start, not margin-left). The visual timeline of a flight runs right to left too, but airport codes, flight numbers, and times stay left to right inside it: wrap them in bdi or give them dir="ltr".
  • Names of places come translated from the server (Munich, München, Monaco di Baviera), while codes such as MUC stay the same in every language.

Announcing streaming results without flooding

A screen reader user cannot see results pile up or a progress bar grow. But announcing every batch, 30 times in 20 seconds, makes the page unusable: each announcement interrupts what they are reading. The screen-readers lesson's rule applies: announce changes that matter, at a pace a person can follow. Never make the list itself a live region.

ts
function createResultAnnouncer(region: HTMLElement, fmt: Intl.NumberFormat) {
  let last = 0;
  return {
    progress(count: number) {
      if (Date.now() - last < 5000) return;
      last = Date.now();
      region.textContent = `${fmt.format(count)} flights found so far, still searching`;
    },
    complete(count: number) {
      region.textContent = `Search complete, ${fmt.format(count)} flights`;
    },
    filtered(count: number) {
      region.textContent = `${fmt.format(count)} flights match your filters`;
    },
  };
}
// <div role="status" aria-live="polite" class="sr-only"></div>
One polite live region, updated at most every 5 seconds while streaming, and always on completion.
  • Announce the filter result count after the traveller stops changing filters, debounced by about 500 ms, so dragging the price slider produces one message, not fifty.
  • The new results pill is a real button with a count in its name. Pressing it re-sorts and moves focus to the results heading, so keyboard users land at the top of the new order.
  • Each result card is an article with a heading such as Emirates, 412 euros, 1 stop, 14 h 40 m, and one primary Select button. The whole card is not a giant link with nested buttons.
  • Respect prefers-reduced-motion for the progress bar and the moving countdown.

Check your understanding · Exercise 13

Results stream in for 25 seconds. What should a screen reader user hear? Select all that apply.

Running it in the interview

This problem is too big to cover fully in 45 to 60 minutes, and the interviewer knows it. What they watch is whether you find the two or three hard parts quickly and go deep on them with numbers, while naming the rest. Here is one way to spend the time.

MinutesWhat to doWhat to draw or say
0 to 6Scope questions: search duration, result count, price freshness, trip types, devices.Write the numbers: 30 s search, 5,000 results, quotes that change.
6 to 12Name the hard parts and the main screen.The results wireframe with progress line, filter rail with counts, pending pill.
12 to 22Long-running search: session id, SSE with polling fallback, merge by flight key, stable order.The stream sequence diagram and the shown versus pending lists.
22 to 30Results list: client-side filtering, counts that skip their own group, URL filters, virtualization.The filter function signature and a count example.
30 to 42Booking: re-pricing, expiring quote, draft on the server, hold countdown, idempotent purchase.The booking state machine and the lost-response sequence.
42 to 50Architecture and data model, then a quick pass on time zones, seats, i18n, accessibility.The component diagram and the core types.
50 to 60Follow-ups and trade-offs you would revisit.What you would measure in production.

Say the trade-offs out loud as you choose: I pick SSE over polling because results flow one way and end, and I keep polling as a fallback because some proxies buffer streams. Interviewers score the reasoning more than the choice. When you skip something, say so and say why: I will not design the seat map in depth; the key point is real buttons with a list alternative.

Think first

Follow-up: a popular route now returns 60,000 itineraries. What changes?

Think first

Follow-up: the traveller opens the same booking in two tabs and pays in both. What happens?

Think first

Follow-up: what would you measure after launch?

Summary

  • A slow fan-out search becomes a search session with an id. Stream it with Server-Sent Events, fall back to polling the same session, and drop events from older searches.
  • Merge results by the flights they contain, not by seller, and keep offers per seller under one itinerary.
  • Separate what has arrived from what is shown. New results wait behind a counted pill, so the row under the traveller's finger never moves.
  • A few thousand results is the size where filtering, sorting, and per-value counts belong in the browser, behind a pure function that can move into a worker.
  • The URL is the search and the filters: reload, share, back, recent searches, and landing pages all read it through one parser.
  • Show every flight time in the airport's local zone, compute durations from instants, and badge day changes from local dates.
  • Search prices are cached guesses. Re-price on select, carry an expiring quote, show any increase before payment, and make the purchase conditional on the shown total.
  • Keep the booking draft on the server with its id in the route, show the hold countdown, and let the traveller extend it.
  • One idempotency key per payment attempt, reused on every retry, turns a lost response into a safe retry instead of a second ticket.

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 3
Mid-level 0 of 4

References

Concepts behind it

Try next