EmpireUI
Get Pro
← Blog8 min read#react#virtualization#performance

React Virtualization: Rendering 100k Rows Without Freezing

Rendering 100k rows in React doesn't have to kill your app. Here's how virtualization works, which library to pick, and the gotchas nobody warns you about.

Terminal screen showing rows of data with green text on a dark background

Why Rendering 100k Rows Destroys Your App

Honestly, most React performance problems aren't subtle algorithm issues — they're just too many DOM nodes. Browsers aren't built to paint 100,000 <div> elements at once. When you mount them all, you're asking the layout engine to compute geometry for every single one before the user sees anything. It's not slow code. It's physics.

The symptom is always the same: the page hangs for two, three, sometimes eight seconds. Scrolling becomes a slideshow. Memory climbs. On lower-end Android devices the tab just crashes. You can throw useMemo at this all day and it won't help, because the bottleneck is the DOM itself, not your JavaScript.

The fix is virtualization — rendering only the rows currently visible in the viewport, plus a small overscan buffer above and below. That's it. Instead of 100,000 DOM nodes, you maintain maybe 30-50 at any given moment. The scroll position is faked by adjusting padding or absolute positioning on a container so the scrollbar looks correct. The illusion is convincing and the performance delta is enormous.

How List Virtualization Actually Works Under the Hood

The core idea is simple. You have a container with a fixed height and overflow: auto. Inside that container you place a single tall element — its height equals rowHeight * totalRowCount. This is what gives the scrollbar its correct proportion. Then, absolutely positioned inside that tall element, you render only the visible rows.

On each scroll event, you recalculate which indices are visible based on scrollTop, containerHeight, and rowHeight. You render those rows (plus a configurable overscan count on each side to avoid blank flicker during fast scrolling). Rows that exit the viewport get unmounted. New ones entering get mounted. From the user's perspective, it looks like a normal scrollable list.

Variable row heights complicate this. When rows aren't fixed-size you need to measure each rendered row and cache its height. This makes the index-to-offset calculation non-trivial — you can't just multiply anymore, you need a running sum. Libraries like TanStack Virtual v3 handle this with an estimateSize function and lazy measurement via ResizeObserver. The first render uses your estimate; after that, real measurements replace it.

react-window vs TanStack Virtual: Which One to Pick

react-window (by Brian Vaughn) is battle-tested and tiny — around 6kB gzipped. It gives you FixedSizeList, VariableSizeList, FixedSizeGrid, and VariableSizeGrid. If your rows are uniform height and you need grid support, it's a strong default. The API is simple and it just works.

TanStack Virtual v3 (formerly react-virtual) is framework-agnostic and gives you a headless hook — useVirtualizer — that returns measurements and offsets. You wire up the DOM yourself. This means more code to write, but zero opinion about your markup. You can use it in React, Vue, Solid, Svelte, or even vanilla JS. It also handles dynamic measurement better out of the box.

For most React projects in 2026, TanStack Virtual v3 is the better pick. It plays well with TypeScript (the types are excellent), handles variable heights without much ceremony, and doesn't lock you into a specific component structure. If you're already using other TanStack libraries — Table, Query, Form — it fits naturally. react-window is still worth knowing, though. Legacy codebases use it heavily and its mental model is helpful for understanding virtualization fundamentals.

Setting Up TanStack Virtual v3 with a Fixed-Size List

Here's a minimal working example. We're rendering 100,000 items with a fixed row height of 48px. The useVirtualizer hook does the math; you handle the DOM.

import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';

const items = Array.from({ length: 100_000 }, (_, i) => ({ id: i, label: `Item ${i + 1}` }));

export function VirtualList() {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48,
    overscan: 5,
  });

  return (
    <div
      ref={parentRef}
      style={{ height: '600px', overflow: 'auto' }}
    >
      {/* Total scroll height */}
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualItem.size}px`,
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            {items[virtualItem.index].label}
          </div>
        ))}
      </div>
    </div>
  );
}

A few things worth noting. The overscan: 5 means 5 rows above and below the viewport are kept mounted — this prevents blank rows during quick scrolls. The transform: translateY(...) approach is faster than setting top directly because it avoids layout recalculation. And the outer container needs an explicit height — without it, the virtualizer can't determine what's visible.

Variable Row Heights and Dynamic Measurement

Fixed heights are easy. Variable heights are where most implementations fall apart. The trick is passing measureElement to the virtualizer and attaching it as a ref callback on each row. TanStack Virtual v3 then uses ResizeObserver internally to track each element's actual rendered height.

import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';

export function DynamicList({ posts }: { posts: { id: number; content: string }[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: posts.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 80, // initial estimate in px
    measureElement:
      typeof window !== 'undefined' && navigator.userAgent.indexOf('Firefox') === -1
        ? (element) => element?.getBoundingClientRect().height
        : undefined,
    overscan: 3,
  });

  return (
    <div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            data-index={virtualItem.index}
            ref={virtualizer.measureElement}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${virtualItem.start}px)`,
              padding: '12px 16px',
            }}
          >
            {posts[virtualItem.index].content}
          </div>
        ))}
      </div>
    </div>
  );
}

The Firefox measureElement caveat is real — getBoundingClientRect can behave inconsistently in Firefox when elements use certain CSS transforms, so the library falls back to the offsetHeight path. If you're hitting measurement drift on scroll, that's usually why. Also note the data-index attribute — TanStack Virtual uses it internally when measureElement is a ref attached to the virtualizer.

Combining Virtualization with React Query and Infinite Scroll

Rendering fast is half the problem. Fetching data efficiently is the other half. You typically don't want to load 100k rows upfront — you want to load them in pages as the user scrolls. Combining TanStack Virtual with TanStack Query's useInfiniteQuery is the standard pattern here.

The key is detecting when the user scrolls near the last virtualized item and triggering fetchNextPage. You do this by checking virtualItems[virtualItems.length - 1]?.index against allRows.length - 1 (minus some threshold). Wrap it in a useEffect that fires whenever virtualItems changes. This gives you a smooth, pagination-free infinite scroll that only renders what's visible — no list of 100k rows in memory, just pages fetched and discarded as needed.

If you're building data tables on top of this, check out our article on React performance optimization patterns — it covers memoization strategies that complement virtualization well. And if your table has a lot of interactive controls in each row, you might also want to look at TypeScript tips for React to keep your row component types clean as complexity grows.

Common Pitfalls and How to Avoid Them

The most common mistake is putting the virtualizer inside a flex parent without a fixed height. Flex containers by default size to their content — your virtualizer container will just expand to fit all rows and you're back to rendering everything. Always give the scroll container an explicit height in pixels or a percentage of a fixed-height parent. height: 100vh is fine. height: auto is not.

Scroll-to-index is another pain point. Virtualizers expose a scrollToIndex method, but if you call it before the virtualizer has measured the target row, the offset will be wrong. For variable-height lists you sometimes need to call it twice — once to trigger measurement, once to land on the correct position. It's janky, but that's the reality of measurement-based virtualization.

Also watch out for onScroll handlers that do expensive work. Adding your own scroll listener on top of the virtualizer's internal one can compound into dropped frames. If you need to track scroll position for something like a 'back to top' button, throttle your listener aggressively — 100ms intervals are fine for UI state updates. The virtualizer's own scroll handling already runs on rAF.

For styling considerations, if you're using glassmorphism card styles inside each row (like the ones described in what is glassmorphism), be careful with backdrop-filter. It triggers compositing layers for every visible row, which can get expensive at 30+ simultaneously visible rows. Test on mid-range hardware, not just your M4 MacBook. And if your row components include animated backgrounds — like particle effects — mount them lazily or skip them entirely inside virtual rows.

Measuring the Actual Impact with React DevTools Profiler

Don't guess. Profile it. Open React DevTools, go to the Profiler tab, and record a scroll interaction. Without virtualization on 10,000 rows, you'll typically see render times of 400-900ms per interaction. With virtualization and overscan set to 5, that same scroll should complete in under 16ms — which is the 60fps budget.

The Chrome Performance panel is also useful here. Look at the 'Rendering' section during a scroll. Without virtualization you'll see massive purple Layout blocks — that's the browser recalculating geometry for thousands of nodes. With virtualization those blocks shrink dramatically. The 'Paint' blocks shrink too. The number of DOM nodes active at any moment should stay roughly constant regardless of how far the user scrolls.

Are you hitting layout thrash inside your row components? That can eat into the budget even with virtualization. The usual culprit is reading offsetHeight or getBoundingClientRect inside a render, which forces a synchronous layout. Keep all measurements inside useEffect or let the virtualizer's measureElement handle it. With those habits in place, 100k rows becomes genuinely manageable — not a scary number at all.

FAQ

Does react-window still work in 2026 or should I switch to TanStack Virtual?

react-window still works fine. It's just not actively maintained at the same pace. If you're starting a new project, TanStack Virtual v3 is the better choice — better TypeScript types, framework-agnostic, and handles variable heights without plugins. But there's no urgent reason to migrate existing react-window code if it's working.

Can I use virtualization with a CSS Grid layout, not just a flat list?

Yes, but it takes more work. TanStack Virtual has a grid virtualizer that handles both row and column virtualization. react-window has FixedSizeGrid and VariableSizeGrid components. The main gotcha is that CSS Grid's auto-placement doesn't mix well with absolute positioning — you'll typically need to compute grid cell positions manually and use absolute or transform-based positioning.

How do I scroll to a specific row programmatically?

Call virtualizer.scrollToIndex(index). For fixed-height lists this works reliably immediately. For variable-height lists where the target row hasn't been measured yet, you may need to call it twice with a short delay, or use the align option ('start', 'center', 'end', 'auto'). If scrolling to dynamic content, consider using a useEffect that watches the data and calls scrollToIndex after the next paint.

My virtualizer shows blank rows during fast scrolling. How do I fix it?

Increase the overscan value. The default is usually 3-5; try bumping it to 10-15 for lists where users are likely to scroll quickly. Higher overscan means more DOM nodes but smoother visual experience. Also make sure you're not doing expensive synchronous work inside your row component — if a row takes 50ms to render, no amount of overscan will prevent flicker.

Does virtualization work with React Server Components?

Not directly. Virtualization requires scroll event listeners and DOM measurements, which only exist in the browser. Your virtualizer wrapper needs to be a Client Component (marked with 'use client'). You can fetch the data on the server with a Server Component and pass it as a prop to a client-side virtualizer — that's the recommended pattern with Next.js App Router.

How does virtualization interact with search and filtering?

Filtering changes the total count and the data array, which the virtualizer handles naturally — just pass the filtered array length to count and index into the filtered array in your item renderer. The tricky part is scroll position: when the filtered results are fewer items, the current scroll offset might exceed the new total height. Most virtualizers reset scroll automatically when count drops significantly, but you may want to call scrollToIndex(0) explicitly when a filter is applied.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React Render Performance: Profiler, Optimizations, Real NumbersReact Architecture & Patterns: The Complete 2026 GuideVirtual List in React: Rendering 100,000 Rows Without Breaking the BrowserView Transitions API: Page Animations Without a Framework