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.
| Question | Answer we assume | What 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. |
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.
| What | Render all 100,000 | Render the window only | Why |
|---|---|---|---|
| Rows in the DOM | 100,000 | About 20 (9 visible plus overscan) | The popup is 320 px tall and a row is 36 px. |
| DOM nodes | About 500,000 | About 100 | About 5 nodes per two-line row. |
| First open (estimate) | Several seconds of blocked main thread | A few milliseconds | Style, layout, and paint scale with node count. |
| Memory (estimate) | Hundreds of MB | About 20 MB for the data plus well under 1 MB for the DOM | The data still lives in memory. Only the DOM shrinks. |
| Each keystroke | Re-render up to 100,000 rows | Re-render about 20 rows | Filtering still touches all 100,000 strings, but in plain JavaScript, not the DOM. |
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
- 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.
- Rewritten on every keystroke. Typing "spr" means three full searches in under a second.
- 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.
- 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.
- 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?
Virtualized rendering still works: the DOM stays at about 20 rows no matter how long the list is. One catch: the spacer would be 2,000,000 x 36 px = 72 million px tall, and browsers cap element height in the tens of millions of pixels (the exact limit differs by browser). Very large lists need a scaled scrollbar (map scroll position to index with a factor) or a capped list like "first 1,000 results, type more".
Client data breaks first: about 400 MB of objects and tens of megabytes to download is too much for a phone. At that size search must move to the server, with the client caching recent results.
A linear scan per keystroke also breaks: 20 times more strings means 20 times the time. You would need an index or the server.
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):
- The user presses Down 4 times. Option 5 is active and rendered.
aria-activedescendant="opt-5". All good. - 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. - 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?
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.
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} />
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.
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.
| Key | What it does here | At 100,000 rows |
|---|---|---|
| Down / Up | Move the active option by one. Opens the popup if closed. | Cheap: the next row is almost always in the overscan already. |
| Page Down / Page Up | Move 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 / End | In 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 / Escape | Pick 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.
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" });
}
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.
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?
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.
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"
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?
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.
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.
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] });
};
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).
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?
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.
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).
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;
}
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.
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?
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 (
scrollToIndexon 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-inputguide 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?
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.
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.
| Part | Where it runs | Responsibility |
|---|---|---|
Combobox root | Main thread | Owns the query, the active index, open state, and the selection. Wires keys to actions. |
ComboboxInput | Main thread | The role="combobox" text field with aria-expanded, aria-controls, and aria-activedescendant. Shows the selected label when closed. |
VirtualListbox | Main thread | The role="listbox" scroll container. Runs the virtualizer, with the active row and the sticky header always in range. |
OptionRow | Main thread | A memoized row: label, second line, highlight, check mark, aria-posinset and aria-setsize. |
SearchClient | Main thread | Posts queries to the worker with a generation number, drops stale replies, falls back to server search. |
| Search worker | Worker thread | Holds the normalized keys, runs the ranked scan, returns an Int32Array of indexes. |
ListStore | Main thread and IndexedDB | Loads the list (IndexedDB, then network), checks the manifest version, holds the one copy of option records. |
SelectionModel | Main thread | Ids or query-with-exclusions. Answers "is this id selected?" and serializes for the form or API. |
LiveAnnouncer | Main thread | A polite live region that announces the result count after typing pauses. |
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.
Think first
A settings page has 6 city comboboxes. What do you share between them, and what stays per instance?
Share the expensive things: one ListStore (one download, one parse, one IndexedDB record) and one worker holding the keys. The worker takes a request id per instance along with the generation, so replies go to the right box.
Keep per instance: the query, active index, open state, selection, and the virtualizer. These are small, and mixing them would make one box react to another's typing.
If the page uses the same list in several tabs, a SharedWorker could share the index across tabs, but support and debugging are weaker; one worker per tab is the safe default.
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.
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 };
| Entity | Lives in | Lifetime |
|---|---|---|
| Option records | ListStore (memory) and IndexedDB | Until the list version changes |
| Search keys | Worker memory (and IndexedDB, to skip normalizing next visit) | Same as the records |
| Results | Component state, newest generation only | Until the next keystroke |
| Query, active index, open | Component state | While the component is mounted |
| Selection | Controlled by the parent through value and onChange | As long as the form needs it |
| Recent picks | localStorage, the last 10 ids | Across 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.
<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
/>
Server endpoints
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 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.
| Detail | Why it matters at this size | What to do |
|---|---|---|
| Stable keys | Index 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 rows | Holding 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 default | Each ResizeObserver measurement can shift every row below it. | Compute heights from row kinds; measure only wrapping text. |
| Layout thrash | Reading 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 cost | Computing match ranges for 60,000 results is wasted work. | Compute in the row, for rendered rows only. |
| Result count announcements | A 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.
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.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope: 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 10 | Size 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 20 | Virtualization 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 30 | Filtering and where it runs. | Normalized keys, tiered substring ranking, the worker with generations and Int32Array results. |
| 30 to 38 | Data loading and architecture. | The architecture boxes and the data flow: CDN, IndexedDB, ListStore, worker, list. Ship versus server versus hybrid, per list. |
| 38 to 45 | Selection, 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?
Use a headless combobox (Downshift, React Aria, Headless UI) for the ARIA and key handling, and TanStack Virtual for windowing. They already handle many hard ARIA and key-handling details, and React Aria documents its screen reader testing.
Then check the seams this guide is about: can the virtualizer keep the active index rendered (a range hook), does the combobox let you set aria-posinset and aria-setsize on options, and can you control filtering yourself so it runs in a worker? If any answer is no, wrap or patch that part instead of rewriting everything.
Think first
Product wants typo tolerance: "sanfransisco" should find San Francisco. How?
Keep substring as the main path. When it returns zero results, run a fuzzy pass in the worker over a smaller candidate set: options sharing at least two 3-letter sequences with the query, found with an n-gram index built at load time.
Score candidates with an edit-distance or Bitap-style scorer (Fuse.js offers one) and show them under a "Did you mean" header, so users understand why they appear.
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-activedescendantmust point at a rendered element, so the virtual range always includes the active row. Every rendered option carriesaria-posinsetandaria-setsizeso position is announced against the real result count.- Keys that jump far (Page Down, End) set an index and call the virtualizer's
scrollToIndex;scrollIntoViewcannot 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.
References
- WAI-ARIA Authoring Practices: Combobox pattern Roles, states, and the keyboard model, including Home and End in editable comboboxes.
- WAI-ARIA Authoring Practices: Listbox pattern Option roles, groups, multi-select, and keyboard interaction for the popup.
- MDN: aria-setsize Declaring the size of a set when only part of it is in the DOM.
- MDN: aria-activedescendant How focus stays on the input while an option is active.
- TanStack Virtual: Virtualizer API
overscan,rangeExtractor,getItemKey,measureElement, andscrollToIndex. - MDN: Transferable objects Moving an ArrayBuffer to or from a worker without copying it.
- MDN: String.prototype.normalize() NFD decomposition, the first step of accent-insensitive search keys.
- match-sorter README A clear, deterministic ranking scheme: equals, starts with, word starts with, contains, acronym, in-order letters.
- web.dev: Interaction to Next Paint (INP) The 200 ms and 500 ms thresholds and how interactions are measured.
- MDN: Visual Viewport API Sizing a full-screen picker around the on-screen keyboard.
Concepts behind it
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.