Skip to content

Fast search over a public dataset

  • Case study 1
  • 150 min
  • Optional · 1 case study needed for the certificate
Case study 1 of 2 · Frontend Architecture and Performance for AI AppsFast search over a public dataset

What you will be able to do

Build a search page over 9,051 real airports, served by a tiny API that you write. First measure a quick prototype. Then move the search to the server, fix the stale-response race that this brings, draw only the rows in view, and split the code. Measure each step under one measure, and decide with numbers.

Case studyPortfolio piece

Time
About 2.5 hours
Runs in
VS Code, Node.js 24, Chromium (Playwright)
Skills used
  • Server paging and filtering
  • The stale-response race
  • TanStack Query keys and cancellation
  • Virtual lists
  • Code splitting with lazy()
  • Measuring with Playwright
  • A fair comparison under one measure

Unzip the starter, open the folder in VS Code, and follow the steps below. They start by installing the project’s packages with npm.

The starter is a working prototype: a small API in Node.js with no packages, and a React page. The finished project is the result, for when you are stuck. The code is under the MIT licence; the data and the world map are in the public domain.

Before you startWhat to know and to have readyAbout 150 minutes

What you need to know

This case study uses the whole course on a project that is not the ticket desk. It leans most on Rendering and effects (the race and the cleanup), Query lifecycle (keys and cancellation), Pagination and forms (pages with a cursor), Measure the browser and Load less work (first rows, the phone-like profile, virtual lists, code splitting).

What you need on your computer

  • Node.js 24 (LTS), npm, VS Code and curl, as in Set up the ticket desk.
  • An internet connection for one npm install (about 130 MB) and for Playwright’s Chromium (about 100 MB, once).
  • About 2.5 hours, of which about 20 minutes are measurements that keep your computer busy.

Tested on macOS (Apple M4, 16 GB) with Node.js 24.18.0 and Chromium 156. The Windows (PowerShell) and Linux commands are the same npm and node commands; they were not run. Every time on this page is from the course team’s laptop, with other work running. Your times will differ; the differences are what matter.

Brightway Travel is a small travel agency. Nadia leads its phone team. While a customer talks, an agent looks up an airport: “Is Orly in Paris?” “Which airport is LCY?” Leo, the agency’s only developer, built a search page in an afternoon. It works, but it feels heavy, and the same search will soon go on the agency’s website, where most visitors use a phone. The people and the agency are invented; the airports are real.

Nadia’s rule is simple: “When I type, I want the rows for what I typed. Not for something that I typed a second ago.”

The objective. A visitor opens the page and types a word. The page must show the right rows, never the rows of an older search. It must do it quickly, on an office computer and on a phone. One measure decides between the versions: the time from opening the page until the rows for the typed word are on the screen and stay there. Four more numbers explain it: the first rows, the time after the last key, the elements in the page, and the time from a click on a row to its details.

Two rows. Starter: the page opens with one JavaScript file of 101 kB gzip, with the map, asks GET /v1/airports/all for 9,051 airports (380 kB gzip, in red), searches them in the browser at every key press, and draws every match: up to 54,338 elements (in red). Finished: the page opens with a first file of 88 kB gzip, asks GET /v1/airports?q=…&limit=50 for one page (about 3 kB gzip, in green). TanStack Query keeps one key per search and closes old requests. A virtual list draws only the rows in view, about 140 elements (in green). After the first rows, the details and map (31 kB gzip) are loaded.
The starter downloads everything and draws everything. The finished page asks the server for one page at a time and draws only what is in view.
Text description of the diagram

On the left, the starter asks for all 9,051 airports, searches them in the browser and draws every match. On the right, the server searches and pages, and TanStack Query keeps each search apart. A virtual list draws only the rows in view, and the details’ code arrives after the first rows.

Part What changes Measured after it
1 nothing: the measure, and the starter’s numbers yes
2 the API searches and pages; the page asks it at every key press yes: the race
3 TanStack Query: a key per search, old requests closed yes
4 a virtual list that loads the next page near the end yes
5 the details and the map in their own file yes, twice
6 the fair comparison, with the other way done well –
7 new input, end to end yes

The data is OurAirports, a site where volunteers keep a list of the world’s airports. It says: “All data is released to the Public Domain, and comes with no guarantee of accuracy or fitness for use.” The full file has 86,236 airports, heliports and closed fields, and it changes every night.

So the project keeps a fixed copy, data/airports.csv: the 9,051 places that are not closed and have an IATA code, the three letters on bags and tickets. That is enough rows for “draw every row” to be slow, and few enough to download at once. DATASET.md in the project is the dataset card: the source, the exact changes, the checksum and the limits.

One row is one airport. The page uses these columns:

Column Meaning Example
iata the IATA code, unique in this file ORY
name the airport’s name Paris-Orly Airport
type large_airport, medium_airport, small_airport, seaplane_base or heliport large_airport
municipality, country the city it serves, and the country Paris, France
latitude, longitude its position, for the map 48.7233, 2.3794

Two checks come before any code. First, the file must be the one that this page’s numbers come from, so the project checks its SHA-256. Second, look at what a short search finds. The search looks for the typed letters anywhere in the code, the name, the city and the country:

Search Airports that match
p 8,741
pa 1,680
par 160
pari 12
paris 8

What this tells us. One letter matches almost everything: almost every name contains “Airport”, and “Airport” contains a p. A real search engine has the same shape: a short search is much more work than a long one. Keep this table in mind for Part 2.

  1. Download the starter project from the box at the top of this page. Unzip it into your projects folder, for example ~/projects. You get the folder airport-search.

  2. In VS Code, choose File > Open Folder and open airport-search. Open a terminal: Terminal > New Terminal.

  3. Install the packages, check the data and run the tests. The commands are the same on macOS, Linux and Windows (PowerShell):

    Terminal window
    npm install
    npm run check:data
    npm test
    Output of the last two commands (parts)
    data/airports.csv: 9,051 airports, 883,430 bytes, SHA-256 15c19f931828… is correct
    …
    ℹ tests 6
    ℹ pass 6
    ℹ fail 0
    …
    Test Files 1 passed (1)
    Tests 2 passed (2)

    package.json already has the packages of the finished project. They are the course’s versions: React 19.3.0, TanStack Query 5.104.1, TanStack Virtual 3.14.15, Vite 8.3.4 and @playwright/test 1.64.0.

  4. Start the API in this terminal. --slow adds a simulated server time, explained below:

    Terminal window
    npm run api -- --slow
    Output
    Airport search API on http://127.0.0.1:8200 (slow: 60 ms + 0.1 ms per match, simulated)
    Data: OurAirports (public domain), 9,051 airports with an IATA code.
  5. Open a second terminal and start the page with npm run dev. Open http://localhost:5173/ and type paris.

Why a simulated server time. On your computer, the API searches 9,051 rows in under 10 ms. A real server is slower, and its time grows with the work: more matches to sort, count and send. --slow makes every answer wait 60 ms plus 0.1 ms for each matching airport. So “p” waits 934 ms and “paris” 61 ms. This is the rule of the course’s mock ticket API. It is constructed, and every number below that depends on it says so. Everything else is real: the data, the browser, the sizes and the drawing.

Leo’s page is short. When it opens, it asks for every airport at once. Then it searches them in the browser at every key press, and draws every match:

src/search/useAllAirports.ts (part)
useEffect(() => {
const controller = new AbortController();
allAirports(controller.signal).then(
(all) => setState({ status: 'success', airports: all.items, error: null }),
(error: Error) => {
if (!controller.signal.aborted) setState({ status: 'error', airports: null, error });
},
);
return () => controller.abort();
}, []);
src/search/ResultList.tsx (part)
const all = useAllAirports();
const airports = useMemo(() => (all.airports ? matchAirports(all.airports, filter) : []), [all.airports, filter]);
// … then every match is drawn: {airports.map((a) => <li key={a.iata}>…</li>)}

This design has a real strength: after the download, a search needs no network. It also has costs that nobody has measured yet.

measure/measure.mjs is built like the course’s own measurement scripts. It opens the page in Playwright’s Chromium, as a new visitor with an empty cache, and reads two marks that the page leaves:

  • performance.mark('search:first-rows') when the first rows are drawn, as in Measure the browser;
  • data-results-for="paris" on the results line: the search that the rows on the screen answer. A small watcher in the page writes down each change of it, with its time.

It has four groups. Each one runs on a fast connection and on the course’s phone-like profile: 150 ms round trip, 1.6 Mbit/s down, and a CPU four times slower.

Group What it does What it reports
open opens the page, 5 times first rows, JavaScript and data downloaded, elements in the page
type opens the page, waits, types paris with 120 ms between keys, 10 times which search the rows answer 3 s later, the time after the last key, long tasks, requests sent and closed early
session the one measure: opens the page and types paris at once, 5 times the time from opening the page until the rows for paris are on the screen and stay
click loads 2,000 rows, then clicks two rows, 3 times the elements in the page, and the time from each click to its details on the screen

Measure a production build, never npm run dev: development mode adds checks that make React much slower. Install Chromium once, then build and serve the page:

Terminal window
npx playwright install chromium
npm run build
npm run preview

In a third terminal, run the four groups. The API keeps running with --slow:

Terminal window
npm run measure -- open --label starter
npm run measure -- type --label starter
npm run measure -- session --label starter
npm run measure -- click --label starter

Each run prints its medians and writes results/starter-<group>.json with every pass, the machine and the load. The course team’s runs (medians; server time simulated):

Measure Fast Phone-like
First rows 1,348 ms 5,224 ms
Data downloaded 381 kB the same
JavaScript (one file) 101.4 kB gzip the same
Elements in the page 54,338 the same
Type paris: last key to the right rows 3.3 ms 8.7 ms
Type paris: long tasks while typing 263 ms 1,245 ms
Open and type paris: the right rows 1,041 ms 3,758 ms

What this tells us. The prototype is good at one thing and poor at three.

  • Good: after the download, a search is instant. The rows for paris were on the screen 3.3 ms after the last key. No network can beat that.
  • Poor: the first rows. Nothing appears until all 381 kB of airports have arrived and all 9,051 rows are drawn. On the phone-like profile, that took 5.2 seconds.
  • Poor: the page freezes while you type. The first key, p, matches 8,741 airports, and React draws a row for each. A long task is work that keeps the browser busy for more than 50 ms, so that it cannot answer a key or a click. While paris was typed, long tasks added up to 263 ms, and to 1,245 ms on the phone-like CPU.
  • Poor: the page is huge. It holds 54,338 elements for a screen that shows about ten rows.

A library does not hand you every card in the catalogue drawer. You ask for “Paris”, and the librarian gives you the first few cards, with a bookmark for the next ones. Pagination and forms built this for the ticket list: the server filters and pages, and the page asks for one page of 50, with a cursor for the next.

  1. Add search() and page() to api/airports.mjs. search() keeps the airports whose code, name, city or country contains the words, and sorts the best first. The code itself comes first, then names and cities that start with the words. The starter’s src/search/match.ts already has the same rules. page() cuts one page and makes the cursor:

    api/airports.mjs (part)
    export function page(matches, limit, after = '') {
    const start = after ? Number.parseInt(Buffer.from(after, 'base64url').toString(), 10) : 0;
    if (!Number.isInteger(start) || start < 0) return null;
    const items = matches.slice(start, start + limit);
    const end = start + items.length;
    return { items, next: end < matches.length ? Buffer.from(String(end)).toString('base64url') : null, total: matches.length };
    }

    Here the cursor holds a position. That is safe only because this data never changes while the API runs. With data that changes, use the key of the last row, as the ticket API does.

  2. Add the route GET /v1/airports to api/server.mjs. It checks limit (1 to 100), type, country and after, and answers 400 with the error shape when one is wrong. Copy the route and api/server.test.mjs (five more checks) from the finished project.

  3. Restart the API (Ctrl+C, then npm run api -- --slow) and try it:

    Terminal window
    curl -s "http://127.0.0.1:8200/v1/airports?q=paris&limit=2"
    curl -s "http://127.0.0.1:8200/v1/airports?limit=500"
    Output (the first answer shortened)
    {"items":[{"iata":"CDG","ident":"LFPG","name":"Charles de Gaulle International Airport","type":"large_airport","municipality":"Paris (Roissy-en-France, Val-d'Oise)","country_code":"FR","country":"France",…},{"iata":"LBG",…}],"next":"Mg","total":8}
    {"error":{"code":"invalid_parameter","message":"limit must be a whole number from 1 to 100."}}

    One page of 2 is a few hundred bytes. For comparison, /v1/airports/all is 2,323,446 bytes of JSON, or 380,168 bytes with gzip.

Now the list asks the server at every change of the search, in an effect. A Load 50 more button asks for the next page. This is the obvious first try, and the one that Rendering and effects warned about:

src/search/ResultList.tsx (part, a first try)
useEffect(() => {
searchAirports({ q, type: filter.type }).then(
(page: AirportPage) => setShown({ q, airports: page.items, next: page.next, total: page.total }),
setError,
);
}, [q, filter.type]);

Copy src/api/airports.test.ts and src/search/ResultList.test.tsx from the finished project, and run npm test. Two of the six page tests fail, as they should at this step:

Output (end)
FAIL src/search/ResultList.test.tsx > draws only the rows in view, not every loaded airport
FAIL src/search/ResultList.test.tsx > an old answer that arrives late does not replace the new one, and its request is closed
…
Tests 2 failed | 4 passed (6)

Build, serve and measure this version with the label paged-naive. Then type paris yourself, quickly:

The airport search page. The search box says paris. The results line says: Showing 50 of 8,741 airports. The first rows are large airports whose city starts with P: Abruzzo Airport in Pescara, Antonio Agostinho-Neto International Airport in Pointe-Noire, Bacha Khan International Airport in Peshawar. None of them is in Paris.
The box says “paris”, but the rows answer “p”: 8,741 airports. The page does not know that it is wrong.
  • answered
  • “p”, answered
  • closed early
One run of typing “paris” with an effect without cleanup, on the course team's laptop (server time simulated). The answer for “p” is sent first and arrives last.
Text description of the chart

Each bar runs from when a request was sent to when its answer arrived: "p" from 6 to 949 ms; "pa" from 129 to 362 ms; "par" from 252 to 334 ms; "pari" from 375 to 440 ms; "paris" from 497 to 562 ms. The answer for “p” arrives after the answer for “paris”, so its rows replace them.

What this tells us. This is the stale-response race of Rendering and effects, on real data. The answer for p needs about 934 ms of server time; paris, sent last, needs 61 ms. So p arrives last, and its rows stay. In the course team’s runs, the rows were wrong 10 times out of 10, on both profiles, and always for p. In the session measure, it was wrong in all 5 passes on both profiles; sometimes the first answer, for the empty search with all 9,051 matches, came last of all. No error appears anywhere.

The first rows came sooner, though: 1,010 ms on the fast profile and 1,929 ms on the phone-like one, against 1,348 and 5,224 ms. The page downloaded 3.1 kB of data instead of 381 kB, and held 333 elements instead of 54,338.

The ticket desk fixed this race twice: with a cleanup in an effect, then with TanStack Query, in Query lifecycle. Use the library here, because it also keeps the pages and closes old requests. Wrap the app in a QueryClientProvider in src/main.tsx (staleTime 30 s, one retry), and add a hook:

src/search/useAirportSearch.ts
export function useAirportSearch(filter: SearchFilter) {
const q = filter.q.trim();
return useInfiniteQuery({
queryKey: ['airports', { q, type: filter.type }],
queryFn: ({ pageParam, signal }) => searchAirports({ q, type: filter.type }, pageParam, signal),
initialPageParam: '',
getNextPageParam: (last) => last.next ?? undefined,
});
}

Three details carry the fix:

  • The key holds the whole search. Each search has its own entry in the cache, and the list shows only the data of the current key. So the rows of p can never appear for paris.
  • The signal. TanStack Query gives each request a signal, and apiGet passes it to fetch. When the search changes, nobody reads the old request any more, so the library closes it.
  • useInfiniteQuery keeps the pages of one search together, and asks for the next one with last.next.

In ResultList.tsx, read the rows from search.data.pages, and let Load 50 more call fetchNextPage(). Run npm test: only the virtual-list test fails now. Then measure with the label paged-query:

Measure (server time simulated) Effect without cleanup TanStack Query
Wrong rows after typing paris (fast / phone-like) 10 of 10 / 10 of 10 0 of 10 / 0 of 10
Requests sent, closed early (fast) 5, 0 5, 2
Requests sent, closed early (phone-like) 5, 0 5, 4
Last key to the right rows (fast / phone-like) never right 65 ms / 164 ms
Open and type paris (fast / phone-like) never right 614 ms / 1,661 ms
  • answered
  • “p”, answered
  • closed early
The same typing with TanStack Query (one run, course team's laptop, server time simulated). A request that was closed early ends where the browser closed it.
Text description of the chart

"p": sent at 5 ms, closed early at 128 ms; "pa": sent at 127 ms, closed early at 250 ms; "par": sent at 250 ms, answered at 330 ms; "pari": sent at 372 ms, answered at 436 ms; "paris": sent at 495 ms, answered at 558 ms.

What this tells us. The race is gone: 0 wrong in every run. The slow request for p is closed by the browser soon after pa is typed, so its answer never comes. On the phone-like profile, more requests were closed, because each one stayed open longer. The right rows came 65 ms after the last key: the server time for paris (61 ms, simulated) plus the trip. The starter needed 3.3 ms, after its long first wait. Every key press now costs a request: that is the trade.

The server sends 50 rows at a time, so the page starts small: 333 elements. But Nadia’s agents scroll, and each Load 50 more adds 50 rows that stay in the page. After 39 clicks, 2,000 rows are there. Load less work taught the fix: a virtual list keeps all the loaded rows as data, and puts in the page only the rows that fit in the scroll box, plus a few above and below.

Replace the list in ResultList.tsx with the finished version. It uses useVirtualizer with rows of 56 px and an overscan of 8, as the ticket desk does. It also asks for the next page by itself when the last drawn row is within 10 rows of the end. So the list scrolls on without a click. The button stays for keyboard users. Run npm test: all 17 checks pass, 11 of the API and 6 of the page. Measure with the label virtual. The click group loads 2,000 rows first; the starter always holds all 9,051:

Measure Starter (9,051 rows) Every loaded row (2,000) Virtual list (2,000)
Elements in the page 54,338 12,033 141
Click a row to its details, fast 30 ms 16 ms 15 ms
The same on the phone-like CPU 119 ms 45 ms 14 ms

(The second click of each pass; the first click was a little slower in every version.)

What this tells us. The virtual list kept the page at 141 elements, however far the agent scrolled. On a fast computer, 2,000 drawn rows were not yet a problem: a click took about the same time either way. On the phone-like CPU, the difference showed: 45 ms against 14 ms, because a click redraws the list to mark the chosen row. And the starter’s 9,051 rows needed 119 ms, past the 100 ms under which a click feels instant. This is the course’s rule again: virtualize because of thousands of rows, not hundreds.

The problem: a map that nobody has asked for yet

Section titled “The problem: a map that nobody has asked for yet”

The details panel draws a small world map with a dot on the airport. The map’s outline is one long SVG path from Natural Earth’s public-domain map: 72,485 characters, 31.4 kB with gzip. It is in the one JavaScript file, so every visitor downloads it before the first rows, even a visitor who never opens an airport.

Load less work taught lazy() and <Suspense>. Load the panel when the visitor opens an airport:

src/App.tsx (part, first try)
const DetailPanel = lazy(() => import('./details/DetailPanel').then((m) => ({ default: m.DetailPanel })));
// …
{selected === null ? (
<p className="muted">Choose an airport to see where it is.</p>
) : (
<Suspense fallback={<p className="muted">Loading the map…</p>}>
<DetailPanel airport={selected} />
</Suspense>
)}

npm run build && npm run size now lists two JavaScript files:

Output of npm run size
File Size Gzip
assets/index-CSERPEPP.js 286.9 kB 87.6 kB
assets/DetailPanel-Lq4YUBTY.js 73.7 kB 31.4 kB
assets/index-D-9IoiRQ.css 2.3 kB 1.0 kB
index.html 0.4 kB 0.3 kB
JavaScript: 2 files, 118.9 kB gzip in all

The hashes in the file names (CSERPEPP) change with every change of the code.

Measure it with the label split-naive:

Measure One file (virtual) Split, first try
JavaScript before the first rows 120 kB 87.6 kB
First rows, fast 1,015 ms 1,012 ms
First rows, phone-like 2,008 ms 1,851 ms
First click on a row (fast / phone-like) 15 / 29 ms 331 / 347 ms
Second click (fast / phone-like) 15 / 14 ms 13 / 14 ms

What this tells us. The first file is 32.4 kB smaller. On the fast profile, the first rows did not change; on the phone-like one, they came 157 ms sooner. That is the course’s finding again: splitting pays on slow networks. But the first click now took over 300 ms, even on the fast profile, where the 31 kB file arrives in a few milliseconds. Why?

Why the first click waited: React’s pause after a fallback

Section titled “Why the first click waited: React’s pause after a fallback”

When the panel’s code is not there yet, <Suspense> shows its fallback, “Loading the map…”. When the code arrives, React does not replace the fallback at once. It waits until at least 300 ms have passed since it showed the fallback. This stops a page from flashing when several parts load one after the other. (The constant is FALLBACK_THROTTLE_MS = 300 in React 19’s source.) So the first click costs about 300 ms, whatever the network. The course team checked that the download was not the cause: a version that loads the file after the first rows, without the next fix, still took 332 ms.

The fix: load early, and keep the old panel while you wait

Section titled “The fix: load early, and keep the old panel while you wait”

Two changes, in src/App.tsx of the finished project:

src/App.tsx (part)
const loadDetails = () => import('./details/DetailPanel');
const DetailPanel = lazy(() => loadDetails().then((m) => ({ default: m.DetailPanel })));
// …
// Load the details' file while the visitor reads the first rows, not when they click.
useEffect(() => afterFirstRows(() => void loadDetails()), []);
// A transition: if the file is not there yet, React keeps the old panel until it is.
const select = (airport: Airport) => startTransition(() => setSelected(airport));
// …
<Suspense fallback={<p className="muted">Loading the map…</p>}>
{selected === null ? <p className="muted">Choose an airport to see where it is.</p> : <DetailPanel airport={selected} />}
</Suspense>
  • Load after the first rows. afterFirstRows() (in src/search/marks.ts) runs the function once the list has drawn its first rows. So the file never competes with the first rows, and it is usually there before the first click.
  • A transition. startTransition() marks the change of the chosen airport as not urgent. If the code is still missing, React keeps the panel that is already on the screen until the new one is ready. So it never shows the fallback, and never waits its 300 ms. For this to work, the <Suspense> boundary must already be on the screen, so it now wraps the “Choose an airport” text too.

Measure again with the label finished:

Measure One file Split, first try Split, finished
First rows, phone-like 2,008 ms 1,851 ms 1,853 ms
First click (fast / phone-like) 15 / 29 ms 331 / 347 ms 48 / 46 ms
Second click (fast / phone-like) 15 / 14 ms 13 / 14 ms 48 / 47 ms

What this tells us. The first rows kept the saving, and the first click fell from over 300 ms to about 48 ms. But read the last row too: every click now takes about 48 ms, against about 15 ms with one file. A transition is less urgent work, and React gives it a lower priority. Both are under 100 ms, so a person does not feel the difference. Still, the code split cost something on every click, and it bought 156 ms on a phone’s first load. For a site whose visitors are mostly on phones, that trade is worth it. For an office tool only, one file would be as good.

Common mistake

A split that made the page slower

Symptom: after code splitting, the first click on an airport shows “Loading the map…” for about a third of a second, on any network. The tests pass.

Cause: a new <Suspense> fallback, then React’s pause of up to 300 ms before it shows the content that replaced a fallback.

Fix: load the file before it is needed, after the first rows. Change the state in startTransition(), inside a boundary that is already on the screen. Then measure the click again: a split is a change like any other, and it can make one thing worse.

Is the server really better, or was the starter just badly built? The fair question compares the server with the best version of “download everything”. That version is in the project as the stage client-virtual. It has the starter’s download and search in the browser, plus the virtual list and the late map file. Only the data strategy differs from the finished page. The course team built and measured it like the others; the chart calls it “All”.

Every version ran the same groups on the same laptop, one after the other. They had the same data, the same API with the same simulated rule, the same build tool and the same typing. The cache was empty each time. The 1-minute load of the computer before the runs was between 4.6 and 16, because other work was running. So read only differences larger than the range of the passes; each JSON file has every pass.

  • Fast
  • Phone-like
The one measure: from opening the page until the rows for “paris” are on the screen and stay. Median of 5 passes on the course team's laptop; server time simulated. Starter; All: everything downloaded, with a virtual list; Final: the finished page. The effect without cleanup is not shown: it never showed the right rows.
Text description of the chart

On the fast profile, the right rows came after 1,041 ms for the starter, 1,058 ms for everything downloaded with a virtual list (“All”), and 600 ms for the finished page. On the phone-like profile: 3,758, 3,674 and 1,555 ms.

Version Right rows, fast Right rows, phone-like Wrong Long tasks while typing (phone-like) Data at the start
Starter 1,041 ms 3,758 ms 0 1,245 ms 381 kB
Everything, done well 1,058 ms 3,674 ms 0 0 ms 381 kB
Server, effect without cleanup never never 5 of 5 0 ms 3.1 kB
Server, TanStack Query 614 ms 1,661 ms 0 0 ms 3.1 kB
Finished 600 ms 1,555 ms 0 0 ms 3.1 kB

What this tells us.

  • The server wins the one measure. The finished page showed the right rows 42% sooner than the starter on the fast profile, and 59% sooner on the phone-like one.
  • “Everything, done well” fixed the freeze, not the wait. With a virtual list, typing caused no long task, and a search after the download took 4.3 ms. But the visitor still waits for 381 kB before the first answer: 3.7 s on the phone-like profile. The download, not the drawing, decides this measure.
  • The last two steps barely moved this measure. The virtual list and the split mattered for scrolling, clicks and the phone’s first rows, not for “open and type”. Each step answered its own number.

The versions were built and tuned while typing paris. Does the result hold for a search that nobody tried? The course team ran the session measure once more with a new word, osaka. Its first letter, o, matches 8,899 airports, so it is another slow first answer:

Version Right rows for osaka, fast Phone-like Wrong
Starter 1,037 ms 3,746 ms 0 of 10
Everything, done well 1,046 ms 3,646 ms 0 of 10
Finished 611 ms 1,548 ms 0 of 10

Then follow one search through the whole system, in the finished page:

Step What happens What proves it
The visitor opens the page one request for the first page of the empty search, about 3 kB the open group: one API request, 141 elements
The first rows are drawn the details’ file starts to load search:first-rows, then a second JavaScript file in the open group
They type o, os … osaka one request per key; the old ones are closed the requests sent and closed early in the type group
The answer for osaka arrives its rows replace the empty search’s rows, and stay data-results-for="osaka" 3 s later
They open an airport the panel and map appear, without “Loading the map…” the click group: under 50 ms
The finished airport search page on a desktop. The search box says paris; the results line says Showing 8 of 8 airports; the first row, CDG Charles de Gaulle International Airport, Paris (Roissy-en-France, Val-d'Oise), France, Large airport, is selected. On the right, the details: Place Paris (Roissy-en-France, Val-d'Oise), France; Type Large airport, scheduled flights; ICAO or local code LFPG; position and elevation; and a world map with a dot in northern France.
The finished page: the rows for “paris”, and the first one opened with its map.

What this tells us. The new word gave the same picture as paris: no wrong rows in the server versions, and the same order of the versions. The result is not a quirk of one search.

Use the finished design for the website and for the agents’ desk. The server searches and pages. TanStack Query keeps each search apart and closes old requests. A virtual list draws the rows in view, and the map’s code comes after the first rows. On the course team’s laptop, it cut the time to the right rows from 1,041 to 600 ms, and from 3.8 to 1.6 s on the phone-like profile, with no wrong rows.

What it costs, said plainly to Nadia and Leo:

  • A request per key press. A search after the last key took about 65 ms instead of a few. A short pause before sending, a debounce of about 150 ms, would cut the number of requests. It would not replace the cancellation, because a slow answer can still arrive late after a pause.
  • The API must stay up. “Everything, done well” works offline once loaded. The server design does not.
  • One more transition per click. About 48 ms per click instead of about 15 ms: invisible to a person, and visible in the numbers.

When the other way is right. “Everything, done well” is a fair choice when the data is small and fixed, the page stays open all day, and the network is fast. Then there is one wait in the morning, and instant searches after it. An office tool for 300 suppliers is such a case. The airport website is not.

  • Measure the version you would build, not a straw man. The starter lost by a lot. “Everything, done well” lost by less, and won one number. Only that comparison is fair.
  • Fix the race in the code, not on your laptop. At full speed, the race did not show once in 10 runs. A test that answers out of order (ResultList.test.tsx) catches it every time.
  • One change, one number. The server pages fixed the first rows and the data, the library fixed the race, the virtual list fixed the elements and the phone’s clicks, and the split fixed the phone’s first load. None of them fixed everything.
  • A fix can cost something elsewhere. The first split made the first click ten times slower, and the finished split costs about 30 ms on every click.
  • Limits of this study. One shared laptop, Chromium only, a simulated phone and a simulated server time, 9,051 rows, one word plus one new word. The server ran on the same computer, so there was no real network between them. Windows, Linux, Firefox, Safari and real phones were not tested.
  • The data has no guarantee. OurAirports says so, and volunteers edit it every day. Show the source and its date on the page, and do not use the list to plan a flight.
  • Credit the source, even when the licence does not ask for it. The page’s header names OurAirports and links to it.
  • Measure only your own services. The measurement script sends many requests. Point it at your own API on your own computer, never at OurAirports or at a service that you do not run.
  • A search box is input. The API checks every parameter and answers 400 for a bad one. React escapes the names that it draws, so a name with HTML in it is shown as text, not run.
  • Keyboard and screen readers. Each row is a button with its name, and the list is a labelled region that Tab can reach. A virtual list has costs here: the browser’s own search finds only the rows in the page. Accessible interaction and the case study Make an AI chat interface accessible show how to test this.

This project shows what employers look for in a frontend engineer: a measure chosen before the code, honest numbers, a race found and fixed with a test, and a decision that names its costs.

What to put in the repository:

airport-search/
README.md what it is, how to run, test and measure it, your results table
DATASET.md the source, the licence, the checksum, the changes, the limits
LICENSE MIT, for the code
docs/results.md your numbers: the computer, the build, the API mode, the medians
api/ the search API and its 11 checks
src/ the page and its 6 tests
measure/ measure.mjs and its four groups
data/airports.csv the fixed copy (public domain)
  1. Make a new Git repository from your project folder, and push it to GitHub. .gitignore keeps out node_modules/, dist/ and results/.
  2. Fill in docs/results.md with your own runs and your computer, and link it from the README.
  3. Describe it in two sentences: “A search page over 9,051 airports with a small Node.js API. I compared the versions under one measure and fixed a stale-response race that appeared 10 times out of 10. The time to the right rows on a phone-like profile fell by more than half.”

Go further: add a debounce, and measure the requests that it saves. Add the country filter that the API already has. Run the measurements on a real phone over Wi-Fi, with Chrome’s remote debugging.

Check your understanding

With the API at full speed, the effect without cleanup showed the right rows in 10 runs out of 10. A teammate says that the race is not a real problem. What do you answer?

Show the answer

The race is in the code: the effect shows whichever answer arrives last. At full speed, every answer took a few milliseconds and arrived in order, so the bug stayed hidden. With a server time that grows with the matches, the answer for “p” arrived last in 10 of 10 runs. A real server, a busy moment or a phone network does the same. The fix is a cleanup or TanStack Query’s key and signal, and a test that answers out of order proves it.

Check your understanding

After its download, the starter showed the rows for 'paris' about 3 ms after the last key; the finished page needed about 65 ms. Why does the recommendation still choose the server?

Show the answer

Because the one measure starts when the page opens. The starter must first download 380 kB and draw every row: the right rows came after about 1 s on the fast profile and 3.8 s on the phone-like one. The finished page needed about 0.6 s and 1.6 s. Even “everything, done well”, with a virtual list, waited 3.7 s on the phone-like profile. The 65 ms after the last key is the price of the server, and it is far smaller than the download.

Check your understanding

The first try at code splitting made the first click on an airport take over 300 ms, even on a fast network. What were the causes, and which two changes fixed it?

Show the answer

The panel’s code was not loaded yet, so the new <Suspense> boundary showed its fallback. Then React waited until 300 ms had passed since the fallback appeared before it showed the panel. The fix has two parts. Load the file after the first rows, so that it is there before the click. And change the chosen airport inside startTransition(), in a boundary that is already on the screen. React then keeps the old panel instead of showing the fallback. The first click fell to about 48 ms.

Recap

  • You chose one measure first: the time from opening the page to the right rows for a typed word, on a fast and a phone-like profile.
  • The prototype downloaded 380 kB and drew up to 54,338 elements. Its searches were instant after the download, but the page froze while you typed.
  • Server pages made the first answer small, and brought the stale-response race: wrong rows 10 times out of 10. TanStack Query’s key and signal fixed it.
  • A virtual list kept the page at about 140 elements. Code splitting helped the phone’s first load, after one fix for React’s pause.
  • The finished page showed the right rows in about 0.6 s instead of 1 s, and 1.6 s instead of 3.8 s on the phone-like profile. “Everything, done well” stayed slow to start.

Next, the second case study makes an AI chat interface usable with a keyboard and a screen reader.

Next: Make an AI chat interface accessible