Performance
medium
mid
If an API returns 5000 records, how would you display them efficiently in a dropdown?
Don't render 5000 DOM nodes. Combine: server-side search/pagination, async incremental load, virtualization (react-window / TanStack Virtual), and a debounced filter input. Most apps need only the last three; large lists need all four.
7 min read·~25 min to think through
Rendering 5000 <option> or <li> nodes destroys layout/paint and is unusable on mobile. The combined fix:
- Search-first, list-second. Most users want one of a handful of items. Render an input that filters the list — the dropdown only shows ~20 matches. Combobox UX (downshift, headlessui Combobox) is the right primitive.
- Virtualization. Even with filtering, the worst case is "no filter" — 5000 rows. Render only the visible window plus a small overscan. Use
react-windowor@tanstack/react-virtual. - Server-side pagination/search. If the dataset can grow, push search to the server and stream the next page on scroll. Don't bring 5000 back to the client when you'll show 20.
- Debounce input. 200–300ms keeps typing fluid; without it you re-filter 5000 records per keystroke.
- Index for fast filter. A pre-built lowercased index avoids
.toLowerCase()on every keystroke. Fuse.js for fuzzy search. - Accessibility. Combobox needs proper ARIA:
role="combobox",aria-expanded,aria-activedescendant, keyboard navigation. Custom dropdowns lose this routinely.
Mobile considerations: native <select> is faster than any custom dropdown for simple cases — use it unless the design requires custom rendering.
Code
tsx
import { useVirtualizer } from "@tanstack/react-virtual";
function Combo({ all }: { all: Item[] }) {
const [q, setQ] = useState("");
const debounced = useDebounce(q, 200);
const filtered = useMemo(
() => debounced ? all.filter((i) => i.name.toLowerCase().includes(debounced.toLowerCase())) : all,
[all, debounced],
);
const parentRef = useRef<HTMLDivElement>(null);
const v = useVirtualizer({
count: filtered.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 32,
overscan: 8,
});
return (
<>
<input value={q} onChange={(e) => setQ(e.target.value)} aria-autocomplete="list" />
<div ref={parentRef} role="listbox" style={{ height: 320, overflow: "auto" }}>
<div style={{ height: v.getTotalSize(), position: "relative" }}>
{v.getVirtualItems().map((row) => (
<div
key={row.key}
role="option"
style={{
position: "absolute", top: 0, left: 0, right: 0,
transform: `translateY(${row.start}px)`,
height: row.size,
}}
>
{filtered[row.index].name}
</div>
))}
</div>
</div>
</>
);
}Follow-up questions
- •How does virtualization affect screen-reader navigation, and how do you mitigate it?
- •When is an HTML <select> better than a custom combobox?
- •How would you implement multi-select with the same constraints?
Common mistakes
- •Trusting the design and rendering 5000 nodes — okay on desktop, dies on mobile.
- •Filtering on the client when the dataset can grow without bound.
- •Building a custom dropdown without ARIA, breaking screen readers.
Performance considerations
- •Virtualization keeps DOM size bounded; CPU per scroll is overscan + visible count.
- •Filtering 5000 strings is fast; what kills perf is recreating React subtrees per keystroke without virtualization.
Edge cases
- •Variable-height rows — use `dynamicSizeList` (react-window) or the `measureElement` API in TanStack Virtual.
- •Selected option scroll-into-view on open — call `scrollToIndex` after mount.
Real-world examples
- •Linear's combobox, GitHub's user-picker, and Notion's mention menu are all virtualized + server-search comboboxes.
Senior engineer discussion
Senior signal: discuss tradeoffs between client-side filtering (instant) and server-side search (scales), ARIA combobox patterns, and how IME composition affects keystroke handling on East Asian inputs.
Related questions
Performance
Medium
7 min