If you build log viewers or data tables in Next.js 16, you've probably watched a page grind to a halt when rendering five thousand rows. React 19 handles fast component updates well, but it can't alter how browser layout engines calculate geometry. Every HTML element costs memory, style recalculation time, and paint effort. When your DOM node count passes a few thousand, scrolling stutters and input updates lag behind.
When standard rendering breaks down
A standard map operation in React works cleanly for 50 items. At 500 complex items—say, table rows containing avatar images, dropdown menus, and dynamic badges—you quickly hit 15,000 DOM nodes. At 5,000 items, you reach 150,000 nodes. Browser memory usage climbs past 400MB for the DOM structure alone, and layout recalculation times jump from 4ms to over 120ms per frame.
List virtualization (also called windowing) fixes this by keeping only visible items inside the DOM tree. If your container frame shows 15 rows, you render around 20 items—adding a small buffer above and below the viewport. As the user scrolls, top rows unmount and bottom rows take their place. Your node count stays fixed at 20 regardless of whether the dataset contains 100 items or 100,000 items.
Measuring the threshold: When windowing pays off
Don't add virtual list libraries by default. Windowing introduces real code complexity around focus state, browser text search, and variable heights. Base your decision on performance profiles rather than assumptions:
- Under 100 simple items: Never virtualize. Direct DOM rendering is simpler and faster to initialize.
- 100 to 500 items: Virtualize only if each list row contains heavily nested elements or component state.
- 500+ items: Virtualize by default, especially on mobile browsers where constrained memory triggers aggressive garbage collection.
Building a virtual list in React 19
The standard tool for modern React applications is @tanstack/react-virtual version 3. It decouples windowing logic from DOM styling, letting you render head-less client components inside Next.js 16 App Router structures. Here is a baseline virtualized log output list:
"use client";
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
interface LogEntry {
id: string;
timestamp: string;
message: string;
}
export function VirtualizedLogList({ logs }: { logs: LogEntry[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: logs.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
overscan: 5,
});
return (
<div
ref={parentRef}
style={{ height: "400px", overflow: "auto", border: "1px solid #ccc" }}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const item = logs[virtualRow.index];
return (
<div
key={virtualRow.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<strong>{item.timestamp}</strong>: {item.message}
</div>
);
})}
</div>
</div>
);
}
This implementation runs smooth 60fps scrolling on low-power hardware. But out of the box, it breaks screen reader behavior and keyboard controls.
The accessibility trap: Focus loss and screen readers
When you strip unrendered DOM elements from the tree, you break three core accessibility features:
- Total size context: Screen readers rely on DOM structure to announce total item counts (for instance, "Item 4 of 5000"). If only 20 elements exist in the tree, VoiceOver announces "Item 4 of 20".
- Focus destruction: If a user focuses an interactive element inside item #12 and scrolls down, item #12 unmounts. The focused node disappears from the DOM, throwing active focus back to the
<body>element. The keyboard user loses their position instantly. - Browser search (Ctrl+F): Off-screen items don't exist in the HTML layout, so native browser search yields zero results for hidden rows.
Fixing ARIA role counts and focus retention
To keep screen reader context intact, expose dataset dimensions using explicit grid roles paired with aria-rowcount and aria-rowindex. You must also maintain focused indices in state so focus gets restored when off-screen items scroll back into view.
Here is an accessible, virtualized data grid built for Next.js 16 client components:
"use client";
import { useRef, useState, KeyboardEvent } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
interface AccountRecord {
id: string;
label: string;
}
export function AccessibleVirtualList({ items }: { items: AccountRecord[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
overscan: 3,
});
const handleKeyDown = (e: KeyboardEvent, index: number) => {
if (e.key === "ArrowDown") {
e.preventDefault();
const nextIndex = Math.min(index + 1, items.length - 1);
rowVirtualizer.scrollToIndex(nextIndex);
setFocusedIndex(nextIndex);
} else if (e.key === "ArrowUp") {
e.preventDefault();
const prevIndex = Math.max(index - 1, 0);
rowVirtualizer.scrollToIndex(prevIndex);
setFocusedIndex(prevIndex);
}
};
return (
<div
ref={parentRef}
tabIndex={0}
role="grid"
aria-rowcount={items.length}
aria-label="User account directory"
style={{ height: "320px", overflow: "auto", border: "1px solid #ccc" }}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
role="presentation"
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const item = items[virtualRow.index];
const isFocused = focusedIndex === virtualRow.index;
return (
<div
key={virtualRow.key}
role="row"
aria-rowindex={virtualRow.index + 1}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div
role="gridcell"
tabIndex={isFocused ? 0 : -1}
onFocus={() => setFocusedIndex(virtualRow.index)}
onKeyDown={(e) => handleKeyDown(e, virtualRow.index)}
style={{
outline: isFocused ? "2px solid #2563eb" : "none",
padding: "12px",
boxSizing: "border-box",
height: "100%",
}}
>
{item.label}
</div>
</div>
);
})}
</div>
</div>
);
}
Handling text search without performance loss
Because hidden elements aren't in the DOM, native Ctrl+F won't match off-screen rows. You have two options to address this issue:
First, implement a dedicated search filter input component above your list. When the user types, filter the array in memory before passing it to useVirtualizer. The hook recalibrates total scroll heights while maintaining focus boundaries.
Second, modern browsers support the hidden="until-found" attribute alongside the beforematch event. However, rendering thousands of items with hidden attributes defeats the memory benefits of windowing. For lists larger than 1,000 items, stick with explicit memory-side string filtering inputs.














