A teammate ships a 40-line typeahead before lunch. An <input>, an onChange that filters the airport list by substring, a <ul> of <li> beneath. The demo list has fifty items. It works beautifully. Merged, deployed, forgotten.

The next sprint swaps the 50-item stub for the real corpus — 10,000 airports. QA opens the dashboard, types “san” at normal speed, and watches the cursor stutter. Characters land out of order in the input. Scroll position jumps around as the <ul> re-paints. The same code that worked flawlessly on fifty items is unusable on ten thousand. Below is that widget — wired to the real 10,000-item list. Type into it the way you would any search box.

Operator · airport search
type “san” to feel it
Corpus size10,000 items
Last keystroke0.0ms
Peak0ms
Frame budget16.7ms
Cards read so far0
Results showing0
First 5 matches
  • Start typing…
Keystroke logempty
  • Start typing…

Count the work. The handler runs items.filter(s => s.toLowerCase().includes(q)) once per keystroke. Eight characters of “sanantoni” over a 10,000-item list means eight keystrokes, each one scanning the full list. The per-keystroke meter above ticks up by ~10,000 substring checks on every tap — one check per item, each one comparing up to twenty characters. By the time the user has typed the last letter the log reports hundreds of thousands of comparisons, all done BEFORE React has started to render anything.

Slide the corpus slider up and down. At 50 items the latency bar sits flat around 2ms — invisible to the user. At 1,000 items it climbs to ~10ms — still smooth. At 10,000 items it peaks past 40ms — past a single 16.7ms frame budget, so the browser has to drop frames to keep up. At 100,000 items the widget is unusable: typing lags by half a second. Same code, same handler, same render path — three orders of magnitude of feel.