cf.completefrontend
My progress
SYSTEM DESIGN · #28

Design a combobox for 100,000 options

A searchable select over a huge list, like every city on Earth or every person in a 50,000-person company. The hard parts are what breaks at that size: rendering only the visible rows while keeping ARIA correct, a keyboard model that jumps through 100,000 rows, filtering every keystroke in a worker without lag, shipping and caching the data, grouped rows, and remembering selections the list no longer shows.

The question

Our design system has a select and an autocomplete. Now teams need a picker where the user types to filter and then picks from a very large list: every city in the world (about 150,000 rows), every employee in the company (50,000 people), or every product SKU (100,000 and growing).

Design the component. Tell me what breaks when the list has 100,000 options, how you keep it fast on a mid-range phone, and how it stays usable with a keyboard and a screen reader.

Real-life examples: The city picker on a travel or shipping form that lists every city in the world, The assignee picker in Jira or Linear at a company with 50,000 people, A product SKU picker in an e-commerce admin, A medical diagnosis code picker over tens of thousands of ICD-10 codes, React Select, Downshift, MUI Autocomplete, and Headless UI Combobox paired with TanStack Virtual or react-window

Requirements and the size math

A combobox is a text input joined to a popup list. You type, the list narrows, and you pick one option. The dropdown-select guide covers the basics for a few hundred items: when to use the native select, the ARIA roles, aria-activedescendant, typeahead, and multi-select chips.

Neighbouring guides cover the rest. The autocomplete guide asks a server for suggestions with debounce and request races, search-results-page covers server search with faceted filters and pages, and emoji-picker covers a fast grid of thousands of cells with local search. This guide is about one thing: what breaks when the list has 100,000 options, and how to fix each break.

Size changes everything. At 30 options you render them all and filter with Array.prototype.filter on each keystroke; at 100,000 the same choices cost seconds and hundreds of megabytes. So the first questions are about size, where the data lives, and who uses it.

QuestionAnswer we assumeWhat it changes
How many options, now and in two years?100,000 today (cities with more than 1,000 people, SKUs, or employees), growing to about 300,000.Rendering, filtering, and loading must all scale. Anything per-option on the main thread is suspect.
Does the user type to filter, or only scroll?Typing is the main path. Scrolling exists but nobody scrolls 100,000 rows.An editable combobox with a fast search engine, not a select-only list.
Is the list the same for every user?Cities and time zones: yes. Employees: the same within a company. SKUs: per store.Shared lists can ship as one cached file. Per-user lists may need server search.
How often does the list change?Cities rarely. Employees daily. SKUs hourly.How we version the cached copy and when we check for a new one.
One value or many?One by default. A bulk-edit screen needs many, up to thousands at once.Selection stored by id, and a 'select all matching' mode stored as a query.
Groups?Cities grouped by country, people by team.Group headers inside the virtual list, which must not be selectable.
Which devices?Desktop browsers and mid-range Android phones, about 40% of sessions on phones.A CPU budget set by a phone 4 to 6 times slower than a laptop, plus a full-screen picker on small screens.
Accessibility target?WCAG 2.2 AA, following the WAI-ARIA Authoring Practices (APG) combobox pattern.The active option must exist in the DOM, positions must be announced, and every key must work.
Wireframe of the city combobox. A shipping form has a City input containing spr. Below it, a popup lists Springfield, Illinois as the active option, then Springfield, Missouri, Spring, Texas, and Palm Springs, California, with 2,341 results at the bottom. Notes say: the input has role combobox and keeps focus; the active option has aria-posinset 1 of 2,341; only about 20 rows exist in the DOM; a live region announces 2,341 results.Shipping addressCitysprSpringfield, Illinois, USSpringfield, Missouri, USSpring, Texas, USPalm Springs, California, US2,341 resultsrole=comboboxfocus stays hereactive optionposinset 1 of 2,341about 20 rows existthe rest is spacerlive region:2,341 results
Focus never leaves the input. The highlighted row is the active option, named by the input's aria-activedescendant. Only the rows near the scroll position exist; the count is announced once typing pauses.

Functional requirements

  • Type to filter 100,000 options. Results update on every keystroke, best matches first, with the matched letters highlighted.
  • Move through results with the arrow keys, Page Up and Page Down, and jump to the first or last result.
  • Show options with two lines ("Springfield" and "Illinois, United States") and group headers (the country, the team).
  • Pick one option, or many in multi-select mode, including "select all 2,341 matching".
  • Keep the chosen value visible even when it is filtered out or far outside the scrolled window.
  • Load the options on first open, cache them between visits, and work for lists that only the server can search.

Non-functional requirements

  • Input latency: each keystroke shows the typed letter within one frame (about 16 ms) and new results within about 100 ms on a mid-range phone. The page's Interaction to Next Paint (INP) stays under 200 ms, the "good" threshold.
  • First open: under 300 ms when the list is cached. When it is not, the popup still opens at once with recent picks, and full search is ready within about 2 seconds on a fast 4G connection.
  • Memory: the open popup adds a few megabytes, not hundreds. The DOM holds tens of rows, never thousands.
  • Accessibility: a screen reader announces the active option, its position ("5,312 of 100,000"), and the number of results after each change.
  • Bundle: the component's own code stays under about 15 KB gzipped; the search code and the data load on demand.

Out of scope

Free-text values that are not in the list (that is a tag input), commands and actions (the command-palette guide), and spelling correction across languages. We also skip the server's search engine itself: we design its API, not its index.

Check your understanding · Exercise 1

Which answers from the interviewer would change this design the most? Select all that apply.

What makes this hard: the size math

Do the arithmetic out loud before any design: interviewers want to see where the cost goes. Every number below is an estimate for a mid-range laptop unless it says otherwise. On a mid-range Android phone, multiply CPU times by about 4 to 6.

The data in memory

One option as a JavaScript object holds an id, a label, a second line, a group, and a precomputed search key. In V8 (the engine in Chrome and Node) each object has a header, each property a slot, and each string its own header plus its characters. A fair estimate is about 200 bytes per option, so 100,000 options take about 20 MB of heap.

That is fine on a laptop and acceptable on a phone, if you keep exactly one copy. Two copies (one in a store, one inside a search library's own index) double it.

The data in the DOM

Rendering every option is where it breaks. A two-line option is about 5 DOM nodes: the li, a label span, a second-line span, and their two text nodes, plus a few more when a mark highlights the match. 100,000 options become about 500,000 DOM nodes.

Each node costs memory for the element, its computed style, and its layout box. Expect hundreds of megabytes (an estimate) and a first render that blocks the page for several seconds on a laptop and far longer on a phone. For comparison, Lighthouse warns when a whole page has more than about 800 nodes in its body and flags an error past about 1,400.

WhatRender all 100,000Render the window onlyWhy
Rows in the DOM100,000About 20 (9 visible plus overscan)The popup is 320 px tall and a row is 36 px.
DOM nodesAbout 500,000About 100About 5 nodes per two-line row.
First open (estimate)Several seconds of blocked main threadA few millisecondsStyle, layout, and paint scale with node count.
Memory (estimate)Hundreds of MBAbout 20 MB for the data plus well under 1 MB for the DOMThe data still lives in memory. Only the DOM shrinks.
Each keystrokeRe-render up to 100,000 rowsRe-render about 20 rowsFiltering still touches all 100,000 strings, but in plain JavaScript, not the DOM.
Two ways to render 100,000 options. Left, render all: every row from 1 to 100,000 is a DOM element, which means about 500,000 DOM nodes, seconds of blocked main thread, and hundreds of megabytes. Right, render a window: an empty spacer stands for rows 1 to 5,300, then about 20 real rows from 5,301 to 5,320 with row 5,312 active, then a spacer for the other 94,680 rows. That is about 100 nodes and a few milliseconds per render, while the data is 20 MB either way.render all 100,000row 1row 2row 3row 4row 5row 6row 7... row 100,000about 500,000 nodesseconds blockedhundreds of MBrender a windowspacer: 5,300 rowsrow 5,301...row 5,312 active...row 5,320spacer: 94,680 rowsabout 20 rows, 100 nodesa few ms per renderdata: 20 MB either way
The data costs the same in both pictures. Only the DOM changes: from half a million nodes to about a hundred, because the spacers give the scrollbar its full height without creating rows.

So virtualization, also called windowing, is mandatory, not an optimization: you render only the rows in and near the viewport, and a tall empty spacer gives the scrollbar the right size. The virtualized-list guide covers windowing itself (spacers, overscan, measuring). Here we care about what windowing breaks in a combobox: an active option that the keyboard moves and a screen reader follows, and a filter that rewrites the list on every keystroke.

The five forces that drive every decision

  1. Huge but mostly unseen. The user sees 9 rows of 100,000. Per-option work must be cheap; per-visible-row work can be rich.
  2. Rewritten on every keystroke. Typing "spr" means three full searches in under a second.
  3. Driven by the keyboard. One key can move the active option from row 0 to row 99,999, and that row must exist in the DOM when it becomes active.
  4. Announced by a screen reader. Assistive tech only knows what is in the DOM, so with 20 rendered rows we must state the real size and position.
  5. Heavy to ship. 100,000 options are megabytes of data, so whether they live on the client at all is its own decision.

Think first

The SKU list grows to 2 million options. Which parts of this plan still work, and which break first?

Virtualization meets ARIA: the active option must exist

In a combobox, DOM focus stays in the text input the whole time, so the user can keep typing. The highlighted option, called the active option, is shown to assistive tech with aria-activedescendant on the input: its value is the id of the option element. The dropdown-select guide explains this pattern.

At 100,000 options it meets a hard rule: aria-activedescendant must point to an element that exists in the DOM. If the id names a row the virtualizer removed, the screen reader has nothing to read. Depending on the reader, the user hears silence or the input again.

Normal windowing renders whatever is near the scroll position. It breaks when the keyboard moves the active option somewhere the window has not reached yet, or when the mouse scrolls the window away from it. Here is the naive version failing in three steps (ids are shortened to opt-5 for the example; real ids come from each option's stable id, as the exercise below explains):

  1. The user presses Down 4 times. Option 5 is active and rendered. aria-activedescendant="opt-5". All good.
  2. They roll the mouse wheel down to row 3,000. The window now renders rows 2,995 to 3,015. Row 5 is removed from the DOM, but the input still says aria-activedescendant="opt-5": a dangling reference.
  3. They press End. The code sets the active option to row 99,999 and calls aria-activedescendant="opt-99999" in the same tick, before the virtualizer has scrolled and rendered that row. For one frame or more the id points at nothing.

Decision How do we keep the active option in the DOM while rendering only a window of rows?

Render every option

Skip virtualization. All 100,000 options are always in the DOM, so every id always exists.

Pros

  • ARIA is trivially correct.
  • Browser find-in-page and native scrolling just work.

Cons

  • About 500,000 nodes, seconds of blocked main thread, hundreds of megabytes. Not an option at this size.

Plain windowing, sync the id after scrolling

Render the visible range plus overscan.

On a key press, scroll first, wait for the range to update, then set aria-activedescendant. Clear it when the active row scrolls out.

Pros

  • Uses any virtual list as is.

Cons

  • Timing bugs: a frame or more with a missing id, and silence when the mouse scrolls away.
  • Clearing the attribute loses the user's place for the screen reader.

Windowing that always includes the active row

The virtualizer's range is the visible rows plus overscan plus the active index, wherever it is.

The active row is rendered at its real offset, even if that is off screen, so its id always exists.

The state change that moves the active index and the render that creates the row happen in the same commit.

Pros

  • The id is never dangling, whatever the mouse or keyboard does.
  • Costs one extra row.

Cons

  • Needs a virtualizer with a custom range hook (TanStack Virtual has rangeExtractor) or a small wrapper.
  • An off-screen active row must not steal visual attention or clicks.

What we pick: Always render the active row. It costs one extra DOM row and removes a whole class of timing bugs. TanStack Virtual supports it directly: its rangeExtractor option receives the visible range and returns the indexes to render, so we add the active index to that list. Then we scroll with scrollToIndex, because MDN and APG also ask that the active option be scrolled into view when it becomes active. If the mouse later scrolls it away, the next key press brings it back.

tsx
const virtualizer = useVirtualizer({
  count: results.length,
  getScrollElement: () => listRef.current,
  estimateSize: () => 36,
  overscan: 6,
  getItemKey: (i) => options[results[i]].id,
  rangeExtractor: useCallback((range: Range) => {
    const rows = defaultRangeExtractor(range);
    if (active >= 0 && !rows.includes(active)) rows.push(active);
    return rows.sort((a, b) => a - b);
  }, [active]),
});

// In the input: only point at a row we know is rendered.
<input role="combobox" aria-expanded={open} aria-controls="city-list"
  aria-activedescendant={active >= 0 ? `city-${options[results[active]].id}` : undefined} />
The range always contains the active index, so the id the input points to always exists.
The mouse has scrolled the list to rows 2,995 to 3,015 while option 5 is still active. Left, plain windowing: the input's aria-activedescendant names opt-5, but that row was removed, so it points at nothing. Right, with the active row pinned: opt-5 is still rendered at its real offset, off screen, so the reference is valid.plain windowinginputactivedescendant: opt-5opt-5: removedpoints at nothingopt-2,995...opt-3,015rendered window onlyactive row pinnedinputactivedescendant: opt-5opt-5: kept, off screenvalid referenceopt-2,995...opt-3,015window plus the active row
One extra row fixes it. The range the virtualizer renders is the visible rows, the overscan, and the active index, so aria-activedescendant always names an element that exists.

Telling the screen reader the real size

A screen reader counts the options it finds in the DOM. With 20 rendered rows it would say "option 3 of 20" for row 5,312, which is wrong and confusing. ARIA has two attributes for exactly this case, when only part of a set is in the DOM: aria-setsize (how many items the whole set has) and aria-posinset (this item's position, starting at 1).

Put both on every rendered option: aria-setsize is the number of current results, not the full list, and aria-posinset is the row's index in the results plus one. If the total is unknown (a server that pages results without a count), ARIA allows aria-setsize="-1".

Support varies by reader: JAWS and NVDA on Windows usually read the position, like "5,312 of 100,000", though this depends on the version and on the user's setting for reporting positions. VoiceOver on macOS and iOS has been less consistent across versions. Treat the attributes as correct markup that most users benefit from, and test with each reader you support (the screen-readers lesson covers how).

Why filtering is the real accessible path

Correct ARIA does not make 100,000 options usable by arrow key. At one option per key press, reaching "Zurich" is tens of thousands of presses. Typing is how everyone, sighted or not, gets there.

So the accessible design is: announce the result count after typing pauses ("2,341 results", through a polite live region that updates about half a second after the last keystroke, not on every letter), keep the active option on the best match, and when the box is empty show a short list of recent or popular options instead of 100,000 rows in alphabetical order.

Overscan

Press Down a few times, then scroll away with the mouse, then press End. Try it with the pin on and off.

A virtual listbox of 100,000 options. Rows 1 to 8 are visible and 10 rows exist in the DOM. The active option is 1, and aria-activedescendant points at a rendered row.rows in the DOMoption 1option 2option 3option 4option 5option 6option 7option 8option 9option 10visibleactive id: opt-1the element existsaria-posinset=1aria-setsize=100000rows in DOM: 10visible from: 1reader: 1 of 100,000pin on: active row always rendered
Press Down a few times, then scroll away with the mouse, then press End. Try it with the pin on and off.

Check your understanding · Exercise 2

The list shows 2,341 results for "spr". The rows at indexes 40 to 60 (counting from 0) are rendered, and the row at index 50 is active. Which statements are right? Select all that apply.

The keyboard across 100,000 rows

The keyboard-navigation lesson and the dropdown-select guide cover the basic key map. Two things change at this size. First, some keys now jump tens of thousands of rows, so "move the highlight" becomes "scroll a virtual list to an index". Second, the usual tool for revealing an element, element.scrollIntoView(), cannot work: the target row usually does not exist yet.

KeyWhat it does hereAt 100,000 rows
Down / UpMove the active option by one. Opens the popup if closed.Cheap: the next row is almost always in the overscan already.
Page Down / Page UpMove by one page: the fully visible rows minus one (7 in a 320 px popup of 36 px rows), so the old edge row stays in view. The APG select-only example uses a fixed 10.Usually lands just outside the window, so the row must be added to the range before it is referenced.
Home / EndIn an editable combobox, APG leaves these to the text caret. In a select-only one, they jump to the first or last option.End means row 99,999: a scroll of 3.6 million pixels and a brand new window.
Typing (editable)Filters the list; the active option resets to the best match.A full search per keystroke: the next two chapters.
Typing (select-only)Typeahead: jump to the next option whose label starts with the typed letters.Search from the active row forward for a prefix match; on a sorted list use binary search.
Enter / EscapePick the active option / close, and restore the previous value.Unchanged.

A note on Home and End in an editable box: users expect them to move the text cursor, and APG agrees. If the product really needs "jump to the last result", some teams add Control plus End while the popup is open. That is not part of the APG listbox popup keys, so document it in the help text and never take the plain keys away from the text field.

Scrolling to an index, not to an element

A virtualizer knows where every row would be, even rows that do not exist: with fixed 36 px rows, row 99,999 starts at 99,999 x 36 = 3,599,964 px. So the move is: set the active index, let the range include it (previous chapter), and ask the virtualizer to scroll to that index.

In TanStack Virtual that is virtualizer.scrollToIndex(index, { align: "auto" }); react-window has the same auto mode (scrollToItem in version 1, scrollToRow in version 2). The auto alignment scrolls only if the row is not already fully visible, and by the smallest distance, which is what a keyboard user expects: Down at the bottom edge moves the list by one row, not by a page.

tsx
function onKeyDown(e: React.KeyboardEvent) {
  const last = results.length - 1;
  const page = Math.max(1, Math.floor(listHeight / ROW) - 1);
  const moves: Record<string, (i: number) => number> = {
    ArrowDown: (i) => Math.min(last, i + 1),
    ArrowUp: (i) => Math.max(0, i - 1),
    PageDown: (i) => Math.min(last, i + page),
    PageUp: (i) => Math.max(0, i - page),
  };
  if (selectOnly) Object.assign(moves, { Home: () => 0, End: () => last });
  const move = moves[e.key];
  if (!move || last < 0) return;
  e.preventDefault(); // stop the page or the caret from moving
  const next = move(active);
  setActive(next); // the range now includes it; the row renders this commit
  virtualizer.scrollToIndex(next, { align: "auto" });
}
One handler moves the index; the range and the scroll follow from it.

Order matters. The state update that changes active is the same render that adds the row to the range and writes the new aria-activedescendant. So the attribute and the element arrive together. The scroll can trail by a frame: the screen reader does not care where the row is on screen, only that it exists.

Sequence for pressing End in a select-only combobox with 100,000 options. The user presses End. The combobox sets the active index to 99,999, which the virtualizer adds to its range. The virtualizer renders the window plus row 99,999 in the DOM. In the same commit the combobox sets aria-activedescendant to opt-99999. Then the virtualizer scrolls to index 99,999. The screen reader reads the option as 100,000 of 100,000.UserComboboxVirtualizerDOM, readerEndactive = 99,999render row 99,999set id: opt-99999scrollToIndex100,000 of 100,000
The row and the id arrive in the same commit, so there is never a frame where the id names nothing. The scroll is a separate step: it only moves what the user sees, and scrollIntoView is never needed.

Held keys and smooth scrolling

Holding Down fires key repeat events, often 20 to 30 times a second depending on the operating system's settings. Each one must finish well inside a frame: move an index, re-render about 20 memoized rows, set one scroll position. Avoid smooth scrolling for keyboard moves: each smooth scroll animates over several frames, and a stream of overlapping animations makes the list lag behind the highlight. Use instant scrolling for keys and keep smooth scrolling, if any, for clicks on a "jump to selected" button.

Check your understanding · Exercise 3

A user in a select-only combobox with 100,000 SKUs presses End. Which steps does a correct implementation take? Select all that apply.

Filtering and ranking 100,000 strings per keystroke

Every keystroke asks two questions of all 100,000 options: does it match, and how good is the match? The answers decide what the user sees in the first 9 rows, which is all that matters for most picks. The command-palette guide covers fuzzy-search UI for a few hundred commands. Here the problem is cost: whatever we pick runs 100,000 times per letter.

Decision Which kind of match should the filter use?

Prefix

An option matches if its label, or any word in it, starts with the query: "san f" matches "San Francisco".

Pros

  • Fastest: on a sorted key list, a binary search finds the range in about 17 steps for 100,000 items.
  • Predictable: users understand why each result is there.

Cons

  • Misses inner matches: "francisco" alone finds nothing unless every word start is indexed.

Substring

An option matches if the query appears anywhere in its search key: key.includes(query).

Pros

  • Finds "francisco" and partial words.
  • Still a tight loop: a linear scan over 100,000 short keys takes a few milliseconds on a laptop (estimate).

Cons

  • Every keystroke scans everything, unless you narrow from the previous results.
  • Short queries match a huge share of the list: "a" matches most cities.

Fuzzy

Letters must appear in order but not together: "sfo" matches "San Francisco". A score rewards matches at word starts and consecutive letters, like fzf. Fuse.js uses an approximate-match algorithm (Bitap) that also tolerates typos.

Pros

  • Forgives typos and abbreviations, great for commands and file names.

Cons

  • Scoring every option costs far more than includes: often tens to hundreds of milliseconds for 100,000 items (estimate; measure on your data).
  • Loose matches surprise users in a place picker: "sprng" matching "Sao Paulo Ring Road".

What we pick: Use ranked substring matching: an option matches if the query is a substring of its key, and ranks higher for an exact match, then a match at the start, then at a word start, then anywhere. This is close to how match-sorter ranks (its README lists equals, starts with, word starts with, contains, acronym, then in-order letters), except that match-sorter breaks ties alphabetically and we break them by popularity. It is cheap enough for a linear scan. Keep fuzzy as an opt-in fallback when there are zero results, run only over a smaller set.

Precompute a normalized search key

Users type "zurich" and expect "Zürich". They type "sao paulo" and expect "São Paulo". Lowercasing and stripping accents at query time would run on 100,000 labels per keystroke, so do it once, when the data loads, and store the result as a key next to each label. The standard trick: normalize("NFD") splits "ü" into "u" plus a combining mark, and a regular expression on the Unicode property \p{M} (marks) removes the marks.

ts
const EXTRA: Record<string, string> = { "ß": "ss", "ø": "o", "ł": "l", "æ": "ae", "đ": "d" };

export function searchKey(text: string): string {
  return text
    .normalize("NFD")            // "ü" becomes "u" + combining mark
    .replace(/\p{M}/gu, "")      // drop the marks
    .toLowerCase()
    .replace(/[ßøłæđ]/g, (c) => EXTRA[c]) // letters NFD does not split
    .replace(/[^\p{L}\p{N}]+/gu, " ")      // punctuation becomes a space
    .trim();
}

// "Zürich" -> "zurich", "Łódź" -> "lodz", "Saint-Étienne" -> "saint etienne"
Run once per option at load time, and once per query.

NFD does not help across scripts: "Москва" will never contain "moskva". For a world city list the fix is in the data: include common alternate names and a transliteration in the key, joined by a separator ("moskva moscow москва"). GeoNames, a common source for city lists, ships alternate names per city for exactly this. This makes keys longer, so cap them: the top 3 alternates per city, not all 50.

Ranking without sorting 60,000 results

The query "a" matches about 60% of a city list. Sorting 60,000 results with a comparator on each keystroke costs tens of milliseconds, so avoid the sort. Store the data pre-sorted by a popularity signal (population for cities, recent activity for people, sales for SKUs).

Then the scan drops each match into one of four buckets by tier (exact, starts with, word start, contains) and concatenates them. Within each bucket the order is already by popularity, because we scanned in that order. One pass, no sort: "spr" puts "Springfield" before "Palm Springs", and big Springfields before small ones.

When a list must be alphabetical instead, such as group order while browsing, sort it once at load time with Intl.Collator and the user's locale. The plain < comparison compares UTF-16 code units and puts "Åland" after "Zurich", while a Swedish collator puts Å after Z on purpose. MDN recommends one collator's compare over localeCompare when sorting large arrays.

Highlighting matched letters

Highlighting needs the match position, but do not compute it in the scan for 60,000 results. Compute it in the row component, only for the 20 rendered rows: find the query in the row's key, map that range back to the label, and wrap it in a mark element.

Mapping back is the subtle part: "Zürich" and "zurich" have the same length here, but "ß" became two letters. Keep a small index map from key positions to label positions when the lengths differ, or highlight on the label with a case- and accent-insensitive comparison using Intl.Collator with sensitivity: "base" for these few rows.

Check your understanding · Exercise 4

The data is sorted by population and the user types "san". Which results come first with tiered substring ranking? Select all that apply.

Where the search runs: main thread, worker, or index

A ranked substring scan over 100,000 keys is a tight loop. On a laptop it might take 3 to 8 ms, which looks harmless. On a mid-range phone it is 4 to 6 times slower: 15 to 40 ms per keystroke, plus rendering.

A fast typist sends a key every 100 to 150 ms, and the browser needs the main thread free to draw each typed letter. When the search shares that thread, letters appear in bursts, and the page's INP (Interaction to Next Paint, the Core Web Vital for responsiveness) suffers. These are estimates; the lab below times the scan in your own browser.

Decision Where should the 100,000-option search run?

Main thread, linear scan

The input's change handler scans the keys and sets the results in state. Often wrapped in a debounce.

Pros

  • Simplest code; synchronous, no messaging.
  • Fine below about 10,000 options.

Cons

  • Every millisecond of scan blocks typing, scrolling, and painting.
  • Debouncing hides the cost by adding delay instead of removing work.

Web Worker, linear scan

A worker (a script on its own thread) holds the keys and scans on request.

The main thread posts the query and receives an Int32Array of matching indexes.

Pros

  • The main thread only renders, so typing stays smooth whatever the scan costs.
  • Same simple scan code, now off the critical path.

Cons

  • Messages are asynchronous: results arrive later and can arrive out of order.
  • Data must reach the worker once, and a long scan cannot see a newer query until it yields.

Precomputed index

Build a structure once: a sorted array of words or a prefix trie for prefix search, or an n-gram index (a map from every 3-letter sequence to the options containing it) for substring search.

Pros

  • Lookups touch a small fraction of the data: microseconds for prefix queries.
  • Scales past a million options.

Cons

  • Build time and memory: an n-gram index can be several times the size of the data.
  • More code, and ranking still needs a pass over the candidates.

What we pick: Run a linear ranked scan in a Web Worker, and add a sorted word index only if profiling says so. At 100,000 options the scan is fast enough that the real problem is where it runs, not how clever it is. A worker removes it from the input's path entirely. An index is the next step at 1 million or more options, and it lives in the same worker, so the main thread code does not change.

The lab below builds 100,000 made-up place names and really runs the scan in this page, timed with performance.now(). Two settings are models, not measurements. The phone setting multiplies the measured time by 5, and the worker setting shows what the main thread would carry if the same scan ran in a worker (about 2 ms of rendering); the lab itself has no worker. "Type spring fast" replays six keystrokes 100 ms apart against those timings.

Device

Filter lab using the main engine. Query "" found 0 results. The search took 0.0 ms, the main thread was blocked 2.0 ms per keystroke, about 0 frames dropped, and results showed after 2.0 ms.first resultsno results yetresults: 0search: 0.0 msmain thread blocked: 2.0 msframes dropped: 0results painted after: 2.0 mspress Type spring fastmain: the scan blocks input
Type a few letters, like spr or field. The first keystroke builds the 100,000 names, so it is slower.

The worker protocol

Two rules make the worker cheap. First, send the data once, in a cheap shape: posting 100,000 objects to a worker copies each one with the structured clone algorithm. Posting one long string of all keys joined by a newline is a single copy, and the worker splits it once.

Second, send results as a typed array of indexes and transfer its buffer: postMessage(msg, [results.buffer]) moves the memory to the main thread instead of copying it. 60,000 matches is 240 KB, moved without a copy.

ts
let keys: string[] = [];
onmessage = (e: MessageEvent) => {
  const msg = e.data;
  if (msg.type === "load") { keys = msg.keys.split("\n"); return; }
  const { gen, query } = msg as { gen: number; query: string };
  const tiers: number[][] = [[], [], [], []];
  for (let i = 0; i < keys.length; i++) {
    const at = keys[i].indexOf(query);
    if (at < 0) continue;
    const tier = keys[i] === query ? 0 : at === 0 ? 1 : keys[i][at - 1] === " " ? 2 : 3;
    tiers[tier].push(i);
  }
  const results = Int32Array.from(tiers.flat());
  postMessage({ gen, results }, { transfer: [results.buffer] });
};
search.worker.ts: one scan, four tiers, results transferred, not copied. For brevity it checks only the first occurrence for a word start.

Cancelling stale searches with a generation counter

The user types "s", "sp", "spr" in 200 ms, and three requests go out. One worker answers in order, but the reply for "s" (60,000 results) can still land after the user typed "spr", flashing results for a query they no longer have; with a pool of workers or a server, replies can even arrive out of order.

The fix is the same as for network races (the request-races lesson): every request carries a generation number, the main thread remembers the latest one it sent, and drops any reply with an older number.

Dropping a stale reply saves rendering, not CPU: the worker still scanned for "s". If scans are long (fuzzy, or a million options), make the worker scan in chunks of about 10,000 and yield between chunks with setTimeout(next, 0) so new messages can run. When a newer query arrives, the old scan sees that its generation is stale and stops.

Do you still need a debounce? For a local worker, no: each scan is short and stale work is cancelled, so a debounce would only add delay. Keep a debounce of about 150 ms for server search, where each keystroke costs a request (the autocomplete and debounce-throttle material).

Sequence of a search in a worker. The input sends s to the SearchClient, which posts generation 1 with s to the worker. The input sends sp, and the client posts generation 2. The worker replies to generation 1 with 61,204 rows, but the client drops it because generation 2 is newer. The worker replies to generation 2 with 1,930 rows as an Int32Array, and the list renders about 20 rows. On the failure path the worker fires an error event, and the client falls back to a chunked scan on the main thread.InputSearchClientWorkerListtyped: sgen 1: styped: spgen 2: spgen 1: 61,204 rowsstale: dropgen 2: Int32Arrayrender 20 rowserror eventfallback scan
The client remembers only the newest generation it sent. Any older reply is dropped before it can repaint the list. If the worker fails to load or crashes, the same scan runs in chunks on the main thread, slower but working.

Two more speed-ups. Narrowing: when the new query extends the old one ("spr" after "sp"), every match must be among the previous matches, so scan only those. First rows first: for an expensive scan, post the first 50 results as soon as they are found, so the visible rows fill in, then post the full list. With tiered ranking, the first batch is final once the higher tiers already hold 50 results, and a sorted word index finds those without a full scan.

Check your understanding · Exercise 5

Search runs in a pool of two workers. The user types "s", "sp", "spr" quickly, and the reply for "sp" arrives after the reply for "spr". Which statements are right? Select all that apply.

Getting 100,000 options to the client, or not

Everything so far assumes the options are already in memory. Getting them there is its own decision, and the answer differs per list.

Start with the size. A city row with an id, name, region, country code, and population, as compact JSON arrays rather than objects with repeated property names, is about 40 to 60 bytes: 4 to 6 MB raw for 100,000 cities, roughly 1 to 2 MB gzipped, and a bit less with Brotli (estimates: names compress well, numbers less so). Adding three alternate names per city can double that. On a fast 4G link (about 10 Mbps), 1.5 MB takes one to two seconds; on a slow one, many seconds.

Decision Should the client hold the full list, or ask the server on every query?

Ship the full list

One static, versioned file on a CDN (cities.v42.json), cached for a year because the name changes when the content does.

The client searches locally in a worker.

Pros

  • Instant results after the first load, including offline.
  • No search server to run or scale; zero requests per keystroke.

Cons

  • A megabyte or more before the first search.
  • Only works for data everyone may see and that changes slowly.

Search on the server

Each query, debounced, calls GET /options?q=spr&limit=50 and the server returns ranked results and a total.

The autocomplete guide covers debouncing and races for this path.

Pros

  • Nothing big to download; works for private, huge, or fast-changing data.
  • Ranking can use server signals, like what this user picked before.

Cons

  • A network round trip per query: 50 to 300 ms, and nothing offline.
  • Scrolling deep into results needs paging, and "Down 2,000 times" becomes many requests.

Hybrid: server search with a client cache

Query the server, but cache results by query, keep recently used and selected options locally, and answer from the cache when a cached query is a prefix of the new one and its result was complete.

Optionally preload the top 5,000 options so common picks are instant.

Pros

  • Fast for the common case, correct for the long tail.
  • Works for private and changing data.

Cons

  • Two code paths to test, and cache rules to get right.

What we pick: Pick per list, behind one data-source interface the component calls. Cities, time zones, and fonts: ship the full list, because it is public, slow-changing, and compresses to a megabyte or two. Employees in a 50,000-person company: ship it per tenant if the data is small and permissions allow, or else go hybrid. SKUs that change hourly and are scoped per store: hybrid. The component never knows which; it calls search(query) and gets indexes or rows back.

Load lazily, start early

Most page views never open the picker, so never put the list in the main bundle or the initial page load. Fetch it on the first sign of intent: when the input gets focus, or on pointerenter over it. The gap between hover and click is often a few hundred milliseconds, which hides part of the download.

Until the data arrives, the popup shows recent picks (kept in local storage) and a "Loading cities" row, and any text the user typed is searched as soon as the worker is ready.

Caching between visits with a version

The HTTP cache helps, but browsers may evict large entries, and the page would still parse several megabytes of JSON and rebuild 100,000 keys on every visit. Store the parsed, normalized keys and rows in IndexedDB instead (the indexeddb lesson): one record per list, holding { version, rows, keys }. On open, read the record, start searching at once, and in the background fetch a tiny manifest (GET /lists/cities/manifest returning { "version": 42, "url": "/lists/cities.v42.json" }).

If the version changed, download the new file, rebuild, and swap it in after the user closes the popup, so results never shift under the cursor. This is stale-while-revalidate for a data file.

Sequence for opening the city picker on a later visit. The combobox asks the ListStore to load cities. The ListStore reads IndexedDB and gets version 41 rows and keys, then tells the combobox it is ready, so search starts at once. In the background the ListStore fetches the manifest from the CDN, which says version 42. It requests cities.v42.json, and the download fails with a network error, so the store keeps version 41 and tries again on the next visit.ComboboxListStoreIndexedDBCDNopen: citiesget citiesv41 rows, keysreadyGET manifestversion: 42GET cities.v42.jsonnetwork errorkeep v41, retry on the next visit
The user never waits for the network on a repeat visit: IndexedDB answers first, and the version check happens behind it. A failed update is not an error the user sees; the old version keeps working.

Failure paths matter. If the manifest fetch fails, keep using the cached version silently. If there is no cache and the list download fails, show an error row with a Retry button inside the popup, keep the text the user typed, and, for the hybrid case, fall back to server search. If IndexedDB is unavailable (some private browsing modes limit it), work from memory for this visit.

Check your understanding · Exercise 6

Which lists are good candidates for shipping the full list to the client? Select all that apply.

Group headers and two-line rows inside a virtual list

Cities are grouped by country and people by team. In a short list you nest options inside a div with role="group" and a label, but a virtual list cannot nest: it positions a flat run of rows by index.

So the first step is to flatten: turn groups of options into one array of rows, where each row is either a header or an option. The virtualizer sees one list of, say, 100,250 rows (100,000 options plus 250 country headers).

ts
type Row =
  | { kind: "header"; group: string }
  | { kind: "option"; index: number; pos: number }; // pos: 1-based among options

function flatten(results: Int32Array, options: Option[]): Row[] {
  const rows: Row[] = [];
  let group = "";
  for (let i = 0; i < results.length; i++) {
    const o = options[results[i]];
    if (o.group !== group) {
      group = o.group;
      rows.push({ kind: "header", group });
    }
    rows.push({ kind: "option", index: results[i], pos: i + 1 });
  }
  return rows;
}
Flattened rows, rebuilt after each search from the result indexes.

Grouping and ranking pull against each other. Ranked results put "Springfield, US" next to "Springfield, AU"; grouping by country splits them. A common answer: group when the query is empty or short (browsing), and switch to a flat ranked list once the user has typed 2 or more letters (searching). If you do group search results, order the groups by their best match, not alphabetically.

Flattening groups for a virtual list. On the left, two groups: Brazil with Sao Paulo, Rio de Janeiro, and Brasilia, and Canada with Toronto and Montreal. On the right, the same as seven flat rows. Row 0 is the Brazil header with no position and top 0. Rows 1 to 3 are options with positions 1 to 3 and tops 28, 64, and 100. Row 4 is the Canada header at top 136. Rows 5 and 6 are options with positions 4 and 5 and tops 164 and 200. Headers are 28 pixels tall and options 36.groupsBrazilSao PauloRio de JaneiroBrasiliaCanadaTorontoMontrealflattenrowkindpostop px0header-01option1282option2643option31004header-1365option41646option5200
The virtualizer sees rows; the screen reader sees positions. Headers take a row but no position, so aria-posinset runs 1 to 5. Offsets come from row kinds, so scrollToIndex is exact without measuring.

Headers are not options

Headers must never become active. The keyboard handler moves over row indexes but skips headers: Down from the last Brazilian city lands on the first Canadian city, two rows later. Page Down counts rows, then steps forward to the nearest option, and a click on a header does nothing. aria-posinset counts options only (the pos field above), so a screen reader hears "4 of 2,341", never a number inflated by headers.

Decision How should group headers appear to assistive tech in a virtual list?

A role=group wrapper per visible run

Wrap each run of rendered rows that share a group in an element with role="group" and aria-labelledby pointing at the header.

Pros

  • Matches the ARIA listbox structure; some readers announce the group name when you enter it.

Cons

  • Fights the virtualizer: rows are positioned one by one, and wrappers must be created and split as the window moves.
  • A group whose header scrolled away has no label element to point at unless you keep it rendered.

Presentational headers, group in the option's text

Render headers with role="presentation" (or aria-hidden), and make sure each option's accessible name or description includes its group: "Springfield, Illinois, United States".

Pros

  • Works with any flat virtual list.
  • Every option is self-describing, which also helps when results are flat and ranked.

Cons

  • Users of assistive tech do not hear the header as a structure.
  • Longer announcements per option.

What we pick: Presentational headers, with the group in each option's second line. For a city or person picker the second line already says where the option belongs, so nothing is lost. The role=group structure is worth its complexity in short, non-virtual lists, which is the dropdown-select guide's case.

Sticky headers

A header that sticks to the top while you scroll through its group tells sighted users where they are. In a virtual list the header row scrolls out and gets removed, so it cannot stick. The fix uses the same tool as the active row: the range extractor always includes the header of the group at the top of the window, and that one row gets position: sticky; top: 0. TanStack Virtual's documentation has a sticky example built this way.

Heights without measuring

Rows now differ in height: a 28 px header, a 36 px option, and perhaps a 52 px option that adds an avatar or a badge line. Measuring every row with a ResizeObserver works, but each measurement can shift rows below it, and positions far down the list are only guesses until the user gets there, so End lands a bit off and then corrects.

Better: when height depends only on the row's kind, compute it. Build an array of row offsets (a running sum of heights) after each search; for 100,000 rows that loop takes about a millisecond (estimate). Then every offset and every scrollToIndex is exact, and you measure only when text can wrap to an unknown number of lines.

Check your understanding · Exercise 7

Results are grouped by country with header rows in the flat list. Which behaviours are correct? Select all that apply.

Remembering selections the list no longer shows

In a short list, the selected option is always somewhere in the DOM, so code often reads selection from it. At 100,000 options the selected city is usually not rendered (it is far from the window) and often not in the results (the user typed something else). Any selection state tied to rows or indexes breaks: an index means a different option after every keystroke. So selection is stored by stable option id, outside the list, and the list only reads it to draw a check mark.

  • Single select: the input shows the selected label when the popup is closed. When the user types, the text becomes the query, but the selection does not change until they pick. Escape or a click outside restores the selected label. APG only requires Escape to close the popup (and allows clearing the box), so restoring is a design choice, and it means a half-typed query never silently replaces a valid value.
  • On reopen: the popup scrolls to the selected option if the query is empty (scrollToIndex on its position, found with a lookup from id to index), and makes it active.
  • Filtered out: if the selection is not in the current results, it still shows in the input or as a chip. Some designs pin a "Selected" section at the top of the list; keep it short.
  • Multi select: chips above or inside the input for the first few picks, then a summary chip like "+ 38 more" that opens a review list. The tag-input guide covers chip editing and keyboard removal.

Selecting thousands: a query, not a list

A bulk-edit screen asks: "assign every SKU matching 'usb-c cable' to this category". That might be 4,700 items. Storing 4,700 ids works but is fragile: in the hybrid data mode the client never even loaded most of them, and the next import adds 30 more that match but are not in the list. The better model stores the intent.

Decision How do we store "select all matching" over thousands of options?

An explicit set of ids

Clicking "select all" copies every result id into a Set.

Pros

  • Simple, and exactly what the user saw.
  • Works the same for 3 or 3,000.

Cons

  • Needs every id on the client, which the server-search mode does not have.
  • Large payloads on submit, and items added later are not included.

A query with exclusions

Store { mode: "query", query: "usb-c cable", excluded: Set of ids }. Unchecking a row adds it to excluded; checking it again removes it.

On submit, send the query and the exclusions, and let the server resolve them, ideally against the list version the user saw.

Pros

  • Tiny, whatever the count.
  • Works even when the client loaded only the first 50 results.

Cons

  • The meaning can drift if the data changes between view and submit.
  • Every row must compute "selected" as "matches the query and not excluded".

What we pick: Support both, and switch modes explicitly. Normal clicks build an id set. "Select all 4,700 matching" switches to query mode, shows that number on the button and in the summary, and sends the list version with the query so the server can warn if the match count changed. Gmail offers users the same choice with its "Select all conversations that match this search" link.

State machine for multi-select. From None, checking a row goes to Ids, an explicit set of ids. From None or Ids, select all matching goes to All matching, which stores the query with zero exclusions. Unchecking a row there goes to All minus some, which stores the query plus excluded ids; re-checking the last excluded row goes back to All matching. Clear returns to None from any state, and changing the query in a query state either freezes the selection or asks first.Nonenothing pickedIdsSet of idsAll matchingquery, 0 excludedAll minus somequery + excludedcheck a rowselect allselect all matchinguncheckre-checkclear: back to Nonenew query: freezeor ask firstaria-selected is computed from the state, never stored per row
Thousands of selected items cost almost nothing, because the query states store the intent, not the ids. The row check mark is computed from the state: in a query state, a row is selected if it matches and is not excluded.

Two details keep this honest. Changing the query while in query mode must not silently change what is selected: either freeze the selection (show "4,700 selected from 'usb-c cable'") or ask before replacing it. And the listbox gets aria-multiselectable="true", with aria-selected on each rendered option computed from the mode, never stored per row.

Check your understanding · Exercise 8

A user picks "Lyon" in a single-select city combobox, reopens it, types "par", then presses Escape. What should happen? Select all that apply.

Architecture, data model, and API

With the hard decisions made, the component splits into parts with one job each. The main rule behind the boundaries: the main thread renders and handles input; it never touches all 100,000 options in a loop. Anything that does runs in the worker or ran once at load time.

PartWhere it runsResponsibility
Combobox rootMain threadOwns the query, the active index, open state, and the selection. Wires keys to actions.
ComboboxInputMain threadThe role="combobox" text field with aria-expanded, aria-controls, and aria-activedescendant. Shows the selected label when closed.
VirtualListboxMain threadThe role="listbox" scroll container. Runs the virtualizer, with the active row and the sticky header always in range.
OptionRowMain threadA memoized row: label, second line, highlight, check mark, aria-posinset and aria-setsize.
SearchClientMain threadPosts queries to the worker with a generation number, drops stale replies, falls back to server search.
Search workerWorker threadHolds the normalized keys, runs the ranked scan, returns an Int32Array of indexes.
ListStoreMain thread and IndexedDBLoads the list (IndexedDB, then network), checks the manifest version, holds the one copy of option records.
SelectionModelMain threadIds or query-with-exclusions. Answers "is this id selected?" and serializes for the form or API.
LiveAnnouncerMain threadA polite live region that announces the result count after typing pauses.
Architecture of the virtualized combobox. In the main thread: ComboboxInput with role combobox reports to the Combobox root, which holds query, active index, and open state. The root updates the SelectionModel, which stores ids or a query, drives the VirtualListbox, which renders about 20 memoized OptionRows, and calls the SearchClient, which keeps the generation counter. The ListStore holds the one copy of the options and sends the keys to the SearchClient. In the worker zone, the Search worker runs the ranked scan; the SearchClient sends it queries and gets indexes back. In the storage and CDN zone, the ListStore reads and writes IndexedDB and fetches the list and manifest from the CDN.Main threadComboboxInputrole=comboboxCombobox rootquery, active, openSelectionModelids or a queryVirtualListbox20 OptionRowsSearchClientgeneration counterListStoreone copy of optionssearch(q)WorkerSearch workerkeys, ranked scanquery, indexesStorage, CDNIndexedDBCDNlist, manifest
The main thread never loops over all 100,000 options while the user types. The worker does that, and the ListStore did the rest once at load time. One ListStore and one worker can serve every combobox on the page.

Why these boundaries? The worker boundary exists for input latency (the search chapter), and the ListStore is separate from the component because many comboboxes on one page (billing city, shipping city) should share one download, one IndexedDB record, and one worker, not three. The SelectionModel is separate because selection has two modes and must be serialized for forms, and none of that belongs in row rendering.

The SearchClient hides whether results come from the worker or the server, so the hybrid data mode is a change in one class.

Data flow, end to end

Big things move rarely: the list file once per version (CDN to ListStore to IndexedDB) and the keys once per page (to the worker, as one string). Per keystroke only a query, a generation number, and an Int32Array cross the thread boundary. On a pick, one id goes to the SelectionModel and out through onChange.

Data flow for the city combobox. Once per version, the CDN sends the list file to the ListStore, which parses and normalizes it and saves rows and keys to IndexedDB. Once per page, the ListStore sends the keys to the worker as one string. On each keystroke, the user types into the Input, the SearchClient posts the query with a generation number to the worker, and the worker returns an Int32Array of indexes. The SearchClient gives the VirtualListbox about 20 rows to render, which the user sees. A pick sends an id to the SelectionModel, which reports it through onChange to the form.CDNcities.v42.jsonListStoreparse, normalizeIndexedDBrows + keysper versionsaveInputquery textSearchClientgen counterWorkerranked scankeys, onceeach keyquery, genInt32ArrayVirtualListboxabout 20 rowsSelectionModelids or queryuserseestypespick: idonChange to the form
Big things move rarely: the file once per version, the keys once per page. Per keystroke only a short query and a typed array cross the thread boundary, and per frame only about 20 rows render.

Think first

A settings page has 6 city comboboxes. What do you share between them, and what stays per instance?

Data model: one copy of everything

Each client entity lives in exactly one place. The worker holds keys (the strings it matches), the main thread holds records (what it displays and selects), and nothing else copies either.

ts
type Option = {
  id: string;          // stable, from the server: "geo:5391959"
  label: string;       // "San Francisco"
  detail?: string;     // "California, United States"
  group?: string;      // "US", shown as "United States"
  disabled?: boolean;
};

type ListSnapshot = { name: string; version: number; options: Option[] }; // ListStore + IndexedDB
type Results = { gen: number; query: string; indexes: Int32Array; complete: boolean };

type Selection =
  | { mode: "ids"; ids: Set<string> }
  | { mode: "query"; query: string; version: number; excluded: Set<string> };

type ViewState = { open: boolean; query: string; active: number }; // component state

// Worker messages
type ToWorker = { type: "load"; keys: string } | { type: "search"; gen: number; query: string };
type FromWorker = { type: "results"; gen: number; indexes: Int32Array; complete: boolean };
Client-side entities. Results are indexes into the one options array.
EntityLives inLifetime
Option recordsListStore (memory) and IndexedDBUntil the list version changes
Search keysWorker memory (and IndexedDB, to skip normalizing next visit)Same as the records
ResultsComponent state, newest generation onlyUntil the next keystroke
Query, active index, openComponent stateWhile the component is mounted
SelectionControlled by the parent through value and onChangeAs long as the form needs it
Recent pickslocalStorage, the last 10 idsAcross visits

Props: one API, any data source

The component-api-design lesson argues for small, composable props. Here the key choice is that the caller passes a source, not an array. Passing 100,000 options as a prop invites every parent render to rebuild them, and it forces the data onto the main thread before anyone opens the picker.

tsx
<Combobox
  label="City"
  source={citySource}          // createListSource("cities") or createServerSource("/api/skus")
  value={cityId}               // controlled: an id, a list of ids, or a query selection
  onChange={setCityId}
  multiple={false}
  groupBy="country"            // headers when browsing; flat ranked when searching
  renderOption={(o, match) => <CityRow option={o} match={match} />}
  emptyQuery="recent"          // what to show before typing: recent picks
  name="city"                  // a hidden input so plain form posts work
/>
Usage: the source decides local or server search; the component does not care.

Server endpoints

http
GET /lists/cities/manifest
200 { "version": 42, "url": "/lists/cities.v42.json", "count": 100000, "bytes": 1180000 }

GET /lists/cities.v42.json        (Cache-Control: public, max-age=31536000, immutable)
200 { "columns": ["id","label","detail","group"], "rows": [["geo:5391959","San Francisco","California, United States","US"], ...] }

GET /api/skus/search?q=usb-c%20cable&limit=50&cursor=eyJvIjo1MH0
200 { "items": [{ "id": "sku:88123", "label": "USB-C cable 2 m", "detail": "Cables" }],
      "total": 4700, "nextCursor": "eyJvIjoxMDB9", "version": 913 }

POST /api/skus/bulk-assign  { "selection": { "mode": "query", "query": "usb-c cable", "version": 913, "excluded": ["sku:88123"] }, "category": "cables" }
409 { "error": "version_changed", "matched": 4731 }
The manifest for shipped lists, and search for server-backed ones.

The list file uses column arrays (columns plus rows) rather than objects, because repeating four property names 100,000 times adds megabytes before compression, and the parser still pays for them after it.

Search returns total, which becomes aria-setsize and the announced count, even when only 50 items come back. And the bulk endpoint answers 409 with the new count when the data changed, so the client can ask "4,731 items now match. Apply to all?"

Check your understanding · Exercise 9

Why does the component take a source instead of an options array? Select all that apply.

Performance budgets, rendering details, and phones

The worker moved search off the main thread. What is left on it per keystroke: the input event, a state update, rendering about 20 rows, style and layout for them, and paint.

The budget comes from INP: web.dev rates an interaction good at 200 ms or less and poor above 500 ms, measured from the input to the next paint. Aim much lower for typing, because users feel lag in letters long before 200 ms: keep the main-thread work per keystroke under about 50 ms on a mid-range phone (the same 50 ms that defines a long task), and under 10 ms on a laptop.

The letter first, the list second

The typed letter must paint now; the result list can paint a frame later. In React, keep the input's value as normal state and pass the results through useDeferredValue (or set them inside startTransition). React then renders the input update first and can interrupt the list render if another key arrives. The worker already makes results arrive later, but rendering them still runs on the main thread, so marking that render as non-urgent keeps the next letter from waiting behind it.

DetailWhy it matters at this sizeWhat to do
Stable keysIndex keys make React reuse a row for a different option after each filter, re-creating highlights and losing measured sizes.Key rows by option id (getItemKey in TanStack Virtual).
Memoized rowsHolding Down re-renders the window 20 to 30 times a second.React.memo rows whose props are primitives: id, active, selected, query. Only the two rows whose active state changed re-render.
No measuring by defaultEach ResizeObserver measurement can shift every row below it.Compute heights from row kinds; measure only wrapping text.
Layout thrashReading offsetHeight after writing styles forces a synchronous layout, once per row.Position rows with transform: translateY from computed offsets; never read layout inside the row loop.
Highlight costComputing match ranges for 60,000 results is wasted work.Compute in the row, for rendered rows only.
Result count announcementsA live region update per keystroke floods screen readers.Announce once, about 500 ms after typing stops.

Measure what you claim. Log a performance.mark when the key event arrives and when the results commit, and send the gap from real users together with the result count and device class (the runtime-performance and core-web-vitals lessons cover tooling). Watch two numbers: input-to-letter-painted (should be one frame) and input-to-results-painted (should be under about 100 ms on phones).

Phones: a full-screen picker

A dropdown popup below an input does not fit a phone. The on-screen keyboard takes roughly 40% of the screen height, the input sits mid-page, and what is left for the list may be two rows. So below about 640 px wide, the combobox opens as a full-screen dialog: a header with a Close button, the search input pinned at the top with focus, and the virtual list filling the rest. The modal-dialog guide's rules apply: trap focus, restore it on close, and keep the page behind from scrolling.

The keyboard still covers the bottom of the list. In most mobile browsers the keyboard shrinks the visual viewport (the part of the page you can see) but not the layout viewport, so 100vh and position: fixed; bottom: 0 still extend under the keyboard.

Listen to window.visualViewport resize events and set the list's height to visualViewport.height minus the header, so the last result sits just above the keyboard. Chrome and Firefox on Android also support the interactive-widget=resizes-content key in the viewport meta tag, which makes the layout viewport shrink instead, but Safari does not yet, so keep the visualViewport code path. Also set enterkeyhint="search", autocapitalize="none", autocorrect="off", and spellcheck="false" on the input so the keyboard does not "fix" "Łódź" into something else.

Two phone screens with the full-screen city picker: a Close button, the title Choose a city, the search input with spr, and results starting with Springfield, Illinois. On the left the list is sized with 100vh, so rows continue under the on-screen keyboard and the last results are hidden. On the right the list is sized with visualViewport.height, so it ends just above the keyboard and every rendered row can be seen.sized with 100vhCloseChoose a citysprSpringfield, ILSpringfield, MOSpring, TXPalm Springs, CASpringdale, ARSpring Hill, FLon-screen keyboardsized to visualViewportCloseChoose a citysprSpringfield, ILSpringfield, MOSpring, TXon-screen keyboardrows hidden under the keyboardlist ends above the keyboard
The keyboard shrinks the visual viewport, not the layout viewport. Size the list from visualViewport.height on its resize event, so the virtualizer's visible range matches what the user can actually see.

A worker scan that takes 25 ms on a phone goes unnoticed because it is off the main thread. Parsing a list file of several megabytes and building 100,000 keys is different: a few hundred milliseconds on a phone (estimate). It happens once per version, before the user types, and the IndexedDB copy skips the normalizing later. If it still shows up as a long task, move the parse into the worker too.

Check your understanding · Exercise 10

On a mid-range Android phone, letters appear late while typing in the city picker. Search already runs in a worker. What are likely causes? Select all that apply.

Running it in the interview

This question rewards candidates who find the breaking points fast. The interviewer already knows you can build a dropdown; they want to hear where 100,000 changes the design and what you do about each break. Spend the first minutes on numbers, not on the popup's border radius.

MinutesWhat to doWhat to draw or say
0 to 5Scope: size now and later, shared or private data, change rate, single or multi, devices, accessibility target."Is the list the same for everyone? That decides whether it can live on the client."
5 to 10Size math and the five forces.100,000 x 200 bytes = 20 MB in memory; 500,000 DOM nodes if rendered. So: virtualize, and search off the main thread.
10 to 20Virtualization and ARIA, then the keyboard.The wireframe, then the window with the active row pinned. Say "aria-activedescendant must point at a rendered element" and "aria-setsize and aria-posinset".
20 to 30Filtering and where it runs.Normalized keys, tiered substring ranking, the worker with generations and Int32Array results.
30 to 38Data loading and architecture.The architecture boxes and the data flow: CDN, IndexedDB, ListStore, worker, list. Ship versus server versus hybrid, per list.
38 to 45Selection, groups, phones, and follow-ups.Selection by id; "select all matching" as a query; the full-screen picker with visualViewport.

Follow-up questions to expect

Draw the wireframe and the size math first: those two pictures justify everything else. Draw the architecture only after virtualization and the worker, so each box arrives with its reason. If the interviewer pushes on one area, follow them: depth on one break beats a shallow tour of all of them.

Think first

Would you use Downshift or React Aria plus TanStack Virtual, or build it yourself?

Think first

Product wants typo tolerance: "sanfransisco" should find San Francisco. How?

Check your understanding · Exercise 11

You have 10 minutes left and have not covered data loading. Which points give the most signal in 2 minutes? Select all that apply.

Summary

  • At 100,000 options, rendering everything means about 500,000 DOM nodes and seconds of blocked main thread. Virtualization is mandatory, and the data itself (about 20 MB of objects) must exist only once.
  • aria-activedescendant must point at a rendered element, so the virtual range always includes the active row. Every rendered option carries aria-posinset and aria-setsize so position is announced against the real result count.
  • Keys that jump far (Page Down, End) set an index and call the virtualizer's scrollToIndex; scrollIntoView cannot reach a row that does not exist yet.
  • Filter with ranked substring matching over precomputed, accent-stripped keys, with data pre-sorted by popularity so tiers need no sort.
  • Run the search in a Web Worker, send keys once as one string, get results back as a transferred Int32Array, and drop stale replies with a generation counter. No debounce is needed for local search.
  • Decide per list whether to ship it (public, slow-changing, a megabyte or two gzipped) or search on the server; load lazily on intent and cache the parsed list in IndexedDB with a version.
  • Flatten groups into typed rows; headers are skipped by the keyboard and not counted in positions; compute row offsets from row kinds instead of measuring.
  • Store selection by stable id outside the list, and store "select all matching" as a query with exclusions, not as thousands of ids.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next