“Build a virtualized autocomplete” extends the search-as-you-type question to handle large datasets — 10,000 countries, 100,000 products, or any list too big to render in DOM. The interview tests whether you understand virtualization, complex ARIA patterns, and the performance characteristics of list rendering.
Functional requirements
- Filter as the user types (or pull filtered results from API) — debounce the input by ~150–300ms so you filter on pauses instead of every keystroke.
- Render up to 100,000 visible options without performance degradation — only a handful are ever in the DOM at once; the rest exist as scroll height, not real nodes.
- Smooth scroll through results — no blank rows or layout thrash as new items enter the viewport, even during a fast flick.
- Keyboard navigation — Up/Down move the highlight, Enter selects, Escape closes; interviewers check that you handle the top and bottom edges of the list.
- Selected option scrolls into view — when the highlight moves past the rendered window, the container has to scroll to bring it back on screen.
- Screen-reader friendly — announce the active option and the result count without moving DOM focus off the input.
Why virtualize?
Rendering 10,000 DOM nodes drops to single-digit fps and uses hundreds of MB of memory. Virtualization renders only ~10–20 visible items + a small overscan buffer.
Libraries: react-window (lightweight), react-virtualized (more features), TanStack Virtual (modern, framework-agnostic).
The basic pattern
- Container has fixed height with overflow scroll — this is the viewport, and its height decides how many rows are visible at any moment.
- Inner sizer has total height = N × itemHeight — this empty spacer gives the scrollbar its correct size so the thumb reflects the full list length.
- Visible items are absolutely positioned at the right offset — each rendered row gets translateY(index × itemHeight) so it lands exactly where it would sit in the full list.
- onScroll updates which items are visible — compute the first visible index from scrollTop / itemHeight, then render that slice plus a few overscan rows above and below to avoid blank gaps.
Filtering
Two strategies:
- Client-side: entire list in memory, filtered with includes() or fuzzy match. Works for <100K items if data is preloaded.
- Server-side: API returns only matching items per query. Required for very large or sensitive datasets.
For interview answers, default to client-side unless the dataset is huge.
Variable-height items
If items are different heights (e.g., some have descriptions), measure them dynamically:
- Use ResizeObserver per row — watch each rendered row and record its real height once it paints instead of guessing from a fixed estimate.
- Cache heights as items render — store measured heights keyed by item so positions stay stable when a row scrolls out and back into view.
- Adjust scroll position when measurements change — when a measured height differs from the estimate, shift scrollTop so the content above the viewport doesn’t visibly jump.
Modern libraries handle this — use them rather than hand-rolling.
Keyboard navigation with virtualization
Tricky: highlighted item may be off-screen. When user presses Down past the visible window:
- Update selectedIndex — track the highlight as a plain index into your data, not a reference to a DOM node that may not currently exist.
- Scroll the container so the new selectedIndex is visible — if the new index sits outside the rendered window, force the list to scroll to it before the next paint.
- Update aria-activedescendant — point it at the ID of the newly highlighted option so the screen reader announces the change.
Use scrollIntoView({ block: ‘nearest’ }) for the simplest correct behavior.
ARIA pattern
The combobox + listbox pattern:
- Input has role=”combobox”, aria-expanded, aria-controls — aria-expanded reflects whether the list is open, and aria-controls points to the listbox’s ID.
- Listbox has role=”listbox” — it wraps the options and needs a stable ID that the input’s aria-controls references.
- Visible options have role=”option” and aria-selected — each rendered row needs a unique ID, and aria-selected marks the current choice.
- aria-activedescendant points to the highlighted option ID — this is how focus stays on the input while the “active” row still gets read aloud.
Critical: aria-activedescendant works even when the focused element is the input. The screen reader announces the active descendant. Don’t move keyboard focus to the option itself.
Performance budget
- Initial render: <50ms for 10K items
- Scroll: 60fps even when scrolling rapidly
- Filter: <100ms after debounce fires
Common mistakes
- Virtualizing a 200-item list (overkill, just render them all)
- Variable-height items without measurement (jumpy scroll)
- Setting tabIndex on every option (breaks keyboard nav)
- Filtering on every keystroke without debounce (jank)
Frequently Asked Questions
How do I handle highlighted item being filtered out?
When filter changes, reset selectedIndex to 0 (first visible item). Or preserve selection by ID and find new index after filter.
Should I use react-window or react-virtualized?
react-window for simple use cases (lighter, smaller bundle). react-virtualized for complex (variable height, masonry, infinite loaders).
How does this work with mobile touch scrolling?
Same as any scrolling list. Overscroll behavior may need overscroll-behavior: contain to prevent body scroll bleeding through.
Useful next steps:
