# Build a Virtualized Autocomplete: Large Lists with Smooth Scroll

Source: https://www.techinterview.org/post/3233475062/build-virtualized-autocomplete-frontend/
Updated: 2026-07-26 · techinterview.org

"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](/big-o-cheat-sheet/) 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.
