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.
| Question | Answer we assume | What 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.
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?
Most interviewers want the slow, streaming search. It touches the network choice (polling or streaming), the merge logic, the rendering strategy, and accessibility, so it shows the most range in one thread.
A close second is price freshness plus idempotent booking, because it shows you think about money and failure, not only happy paths. Mention both, go deep on one, and say which you skipped.
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?
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.
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.
Long-running search: getting results that arrive over 30 seconds
The naive build sends GET /search?... and waits. The backend waits for all providers, so the request takes as long as the slowest one: 20 to 30 seconds of spinner. Many travellers leave in that time, and on mobile a request that long often dies when the phone switches networks. Worse, the fastest providers had good answers after 2 seconds that nobody saw.
So the backend turns a search into a search session: POST /searches starts the fan-out and returns a searchId at once. Results collect on the server under that id as providers answer. The open question is how the browser gets them.
Decision How should the client receive results that arrive over 5 to 30 seconds?
What we pick: Server-Sent Events on top of the search session, with polling as the fallback. The session id is what makes this safe: if the stream breaks, the client reconnects with Last-Event-ID or switches to GET /searches/{id}?after=cursor and loses nothing. SSE fits because results flow one way and end. Polling alone is also a fair answer, as Skyscanner shows: with a 1 second interval it adds little delay to a 30 second search. The server-sent-events lesson covers the wire format and the reconnect rules.
Two details need care. If the traveller edits the search while one is streaming, close the old EventSource and start a new session; tag every event with its searchId and drop any that do not match the current one, the stale-response race the request-races lesson describes. And when the complete event arrives, the client calls close() itself: if the server simply ends the response, EventSource treats that as a dropped connection and reconnects.
Merging and deduping itineraries as they stream
Three agencies may sell the same two flights. The same flights are the same itinerary, with several offers (a seller and a price each). The key for an itinerary is its flights, not the seller: for each segment, the marketing carrier, flight number, and local departure date, joined in order, for example LH761@2026-12-20|LH900@2026-12-21. Ideally the backend dedupes too, but providers arrive at different times, so the client must merge across batches anyway.
function mergeBatch(store: ResultStore, batch: ItineraryDto[]) {
const added: string[] = [];
for (const dto of batch) {
const key = dto.segments.map((s) => `${s.carrier}${s.number}@${s.departDate}`).join("|");
const existing = store.byKey.get(key);
if (!existing) {
store.byKey.set(key, toItinerary(key, dto));
added.push(key);
continue;
}
for (const offer of dto.offers) existing.offers.set(offer.sellerId, offer);
existing.bestPrice = Math.min(...[...existing.offers.values()].map((o) => o.priceMinor));
}
store.version++;
return added; // new keys go to the pending buffer, not straight into the shown list
}
Keeping the order stable while results arrive
If every batch re-sorts the list, the row under the traveller's thumb moves just as they tap it, and they open the wrong flight. This is a layout shift caused by data, and it hurts trust more than a slow page. The fix is two lists: the result store has everything that arrived, and the shown list is a snapshot of keys in a fixed order.
- During the first 2 to 3 seconds, or until the first 20 results, apply batches straight away. Nobody is reading yet, and an empty screen is worse than movement.
- After that, new itineraries go to a pending set. A pill at the top of the list says 12 new results, and also says when any of them is cheaper than the top shown result. Tapping it re-sorts and scrolls to the top.
- Prices of rows already shown can update in place when a cheaper offer arrives, but the row keeps its position until the next apply.
- When the search completes and the traveller is still at the top of the list, apply pending results automatically.
- Any filter or sort change also applies the pending set, because the traveller just asked for a new order anyway.
Progress needs the same honesty. Show how many providers have answered out of how many were asked (31 of 48 sources), a thin bar that grows with that ratio, and a short line when the search is complete. A spinner that turns until the end hides that results are already useful, and a bar that grows by time lies when one slow provider holds everything up.
Some providers never answer. The backend stops waiting after 30 seconds and sends complete with the list of sources that timed out. The page then says Search complete, 46 of 48 sources, and nothing more: the traveller does not need provider names, only to know the list is final. If fewer than half the sources answered, offer a Search again button, because the result set is probably missing good fares.
Think first
Twelve seconds into a search, the phone switches from Wi-Fi to 4G and the SSE connection drops. What happens, step by step, and what does the traveller see?
EventSource reconnects by itself after a short delay and sends the Last-Event-ID header with the id of the last batch it received. The server replays only the batches after that id from the search session, so nothing is lost or duplicated. Even if a batch did arrive twice, the merge is keyed by flights and sellers, so applying it again changes nothing.
If reconnecting fails a few times, the client closes the EventSource and polls GET /searches/{id}?after=cursor instead. The traveller sees at most a pause in the progress line, never an error, because the results already on screen are still valid.
Check your understanding · Exercise 3
Eight seconds into a search, the traveller is reading the third result. A batch of 40 itineraries arrives, 5 of them cheaper than anything shown. What should happen? 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?
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.
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 };
}
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?
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.
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)
}
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.
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.
| Fare family | Cabin bag | Checked bag | Seat choice | Changes | Refund |
|---|---|---|---|---|---|
| Basic | 1 small bag under the seat | Not included | Paid | Not allowed | No |
| Standard (plus 38 euros) | 1 bag up to 8 kg | 1 bag up to 23 kg | Free standard seats | Fee of 70 euros | No |
| Flex (plus 145 euros) | 1 bag up to 8 kg | 2 bags up to 23 kg | Free, including extra legroom | Free | Yes, 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?
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.
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
expiresAtpasses, 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?
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.
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.
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" };
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?
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.
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, arole="row"per seat row, and each seat button inside arole="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?
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.
6c1e in its key store and returns the first result. A new key here would have bought a second ticket.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}
}
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.
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.
| Piece | Responsibility | Why it is separate |
|---|---|---|
| Route pages | Landing pages (server rendered), search results, and booking steps as routes. | Each needs a different rendering strategy and code bundle. |
| Search params parser | URL 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 client | Starts 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 store | Merged 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 engine | Pure function from results and filters to ordered keys and counts. | Pure and testable; no UI or network inside. |
| Booking client | Pricing, the draft, holds, seats, and the purchase with idempotency keys. | Money paths get the strictest error handling and tests. |
| Server state cache | TanStack 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.
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.
| Data | Where it lives | Lifetime |
|---|---|---|
| Search and filters | URL | As long as the link exists. |
| Itineraries and offers | Result store in memory (or a worker) | One search session; dropped on a new search. |
| Priced offer, booking draft, seats | Server, mirrored in the server state cache | Until the hold expires or the booking completes. |
| Form fields being typed | Component form state | Until Continue saves them to the draft. |
| Recent searches, alert ids, currency | localStorage | Across visits, capped at 10 searches. |
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 };
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.
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)
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" }
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?
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 /searchesand puts thesearchIdin 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-Controlmax-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.DateTimeFormatpicks 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, notmargin-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 inbdior give themdir="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.
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>
- 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
articlewith 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-motionfor 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.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 6 | Scope questions: search duration, result count, price freshness, trip types, devices. | Write the numbers: 30 s search, 5,000 results, quotes that change. |
| 6 to 12 | Name the hard parts and the main screen. | The results wireframe with progress line, filter rail with counts, pending pill. |
| 12 to 22 | Long-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 30 | Results list: client-side filtering, counts that skip their own group, URL filters, virtualization. | The filter function signature and a count example. |
| 30 to 42 | Booking: re-pricing, expiring quote, draft on the server, hold countdown, idempotent purchase. | The booking state machine and the lost-response sequence. |
| 42 to 50 | Architecture and data model, then a quick pass on time zones, seats, i18n, accessibility. | The component diagram and the core types. |
| 50 to 60 | Follow-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?
Client-side filtering of 60,000 rows with counts is still possible in a worker, but the stream itself becomes megabytes and slow on mobile. Move the bulk to the server: it keeps the full set, sends the best 3,000 by the current sort with exact counts for all filters, and re-queries when a filter would reveal rows the client does not have.
The filter engine interface stays the same, which is why it was kept pure and behind one function.
Think first
Follow-up: the traveller opens the same booking in two tabs and pays in both. What happens?
Each tab generated its own idempotency key, so the keys do not protect this case. The server-side guard does: a booking draft can have only one successful purchase, and the second tab's request is refused with ALREADY_BOOKED and the existing confirmation.
The client can also listen on a BroadcastChannel named after the booking id and show the other tab's confirmation, so the second tab never reaches Pay.
Think first
Follow-up: what would you measure after launch?
Time to first result and time to complete per search, the share of selects that hit a price change and by how much, hold expiries per booking step, booking submissions with an unknown outcome, and INP on the results page on low-end Android. Each maps to a decision in this design, so each tells you if that decision holds up.
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.
References
- MDN: Using server-sent events EventSource, event ids, and reconnection.
- MDN: Intl.DateTimeFormat The timeZone option used to show airport local times.
- Stripe docs: Idempotent requests How a payment API uses idempotency keys and how long it keeps them.
- IETF draft: The Idempotency-Key HTTP header field An expired draft many APIs still follow: the header, 409 for a repeat still in progress, 422 for a reused key with a different body.
- Skyscanner: Flights Live Prices API overview A real create-then-poll search session, and why slow suppliers often have the best itineraries.
- EUR-Lex: Regulation (EC) No 1008/2008 Article 23: the final price shown from the start, optional extras opt-in.
- WCAG 2.2 Understanding: Timing Adjustable Why a fare hold needs a warning and a way to extend it.
- WAI-ARIA APG: Grid pattern Keyboard behaviour for a two-dimensional set like a seat map.
- MDN: aria-live Polite live regions for result counts.
- web.dev: Virtualize large lists with react-window Rendering only the visible rows of a long list.
Concepts behind it
- Server-sent eventsOne HTTP response that never finishes: the server keeps writing small text events to it, and the browser's
EventSourceparses them, reconnects on its own, and resumes where it left off. The simplest push channel when data flows from server to client. - 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.
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- 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.
- Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- 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.