React vs Vue Virtualized Lists: 12 Steps, 60 FPS [2026]

Render a table with 50,000 rows in React or Vue without a plan, and the browser tab locks up before a single pixel of scroll happens. The DOM was never built to hold tens of thousands of live nodes at once, and both frameworks hit the same wall for the same reason: every node you mount costs layout, paint, and memory, whether or not the user can see it. The fix is list virtualization, and as of September 2026 the tooling for it looks different depending on which framework you’re shipping.

This tutorial builds the identical virtualized data grid twice: once in React 19.3 with @tanstack/react-virtual, and once in Vue 3.5 with both @tanstack/vue-virtual and the older, still widely used vue-virtual-scroller. By the end you’ll have two working projects, a full understanding of why virtualization works the way it does, and a clear picture of where the two ecosystems genuinely diverge instead of just swapping useState for ref.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

Why Rendering 10,000+ Rows Breaks Your App

A plain .map() over an array in React, or a v-for loop in Vue, creates one DOM node per item. That’s fine at 200 rows. At 10,000 rows it means 10,000 live elements sitting in the render tree, each one tracked by the browser’s layout engine even when 9,960 of them are scrolled out of view. Chrome still has to compute their position, their box model, and their paint layer on every reflow. The result is a multi-second initial mount, a scroll handler that drops frames, and a memory footprint that keeps climbing as users load more pages of data.

List virtualization (also called windowing) solves this by rendering only the rows currently inside, or just outside, the visible viewport. A 600px-tall container showing 30px rows only needs about 20 real DOM nodes, plus a small overscan buffer, no matter whether the underlying dataset has 100 rows or 10 million. The container gets a spacer element sized to the full scrollable height, and as the user scrolls, the visible slice of rows is recalculated and the DOM nodes are recycled or replaced. Both React and Vue converge on the same core algorithm; they just expose it through very different APIs, which is exactly what this tutorial digs into.

The npm registry backs this up with real numbers. As of this week, @tanstack/react-virtual is pulling roughly 17.2 million weekly downloads on npm, its Vue counterpart @tanstack/vue-virtual sits at about 2.59 million, and the older vue-virtual-scroller library trails at around 360,000. That’s a real gap in adoption, not just a difference in framework popularity, and it’s worth understanding before you pick a library for a production dashboard.

The underlying cost is layout thrashing. Every time the browser needs to compute the position of an element, it has to walk the render tree and figure out box sizes, margins, and offsets for everything else that could affect that element’s position. With 10,000 unvirtualized nodes in a scroll container, a single scroll event can trigger a reflow that touches a meaningful fraction of those nodes, even though only 20 of them are actually visible. Paint costs compound the problem: each row typically has its own background, border, and text layer, and the compositor has to rasterize all of it, not just the parts inside the viewport clipping rectangle. Virtualization sidesteps both costs by keeping the live node count bounded and constant, so reflow and paint work stays flat as your dataset grows from 1,000 rows to 1,000,000.

React Hooks vs Vue Composables: Why the APIs Feel Different

Before writing any code, it helps to understand why @tanstack/react-virtual and @tanstack/vue-virtual look almost identical in their configuration object but feel different to use. React’s hook model re-runs your entire component function on every render, so useVirtualizer has to be careful about what it recomputes and what it memoizes internally between calls — that’s why the React adapter leans heavily on useRef for the scroll container and treats the returned virtualizer instance as a stable reference across renders.

Vue’s composition API works differently: a <script setup> block runs once per component instance, and reactivity is driven by proxies that track exactly which values a template actually reads. That means @tanstack/vue-virtual‘s useVirtualizer doesn’t need to guard against re-running setup logic the way the React version guards against re-running render logic — it just returns a reactive object, and Vue’s own dependency tracking figures out when the template needs to update. In practice, this is why the Vue version of the row list in Step 7 has noticeably less boilerplate around memoization than the React version in Step 3: Vue’s compiler is doing dependency tracking that React asks you to do manually with memo() and useMemo().

Neither approach is objectively faster for virtualization specifically — both frameworks bottleneck on the same DOM operations, not on their own internal reactivity systems. The difference shows up in code you have to write and maintain, not in runtime frame rates. Teams that already have a strong opinion on hooks versus composables will likely find that opinion holds for virtualized lists too, rather than being overturned by anything specific to windowing.

Prerequisites: Versions and Tools You Need

Confirm these versions before starting. Mismatched major versions are the single most common cause of “the tutorial doesn’t work” reports on virtualization libraries, since both TanStack Virtual and the Vue ecosystem ship frequent minor releases.

  • Node.js 20.x or later (Node 22 LTS recommended)
  • npm 10+ or pnpm 9+
  • React 19.3.0 (released September 9, 2026) and ReactDOM 19.3.0
  • Vue 3.5.42 (released August 27, 2026)
  • @tanstack/react-virtual 3.14.12
  • @tanstack/vue-virtual 3.13.38
  • vue-virtual-scroller 3.0.5 (for the alternative Vue approach in Step 9)
  • react-window 2.3.1 and react-virtuoso 4.18.13 (referenced for comparison)
  • Vite 6 for both project scaffolds
  • TypeScript 5.6+ (optional, but used in the code samples)
  • Chrome or Edge 128+ for the DevTools profiling steps

Run node -v and npm -v now to confirm your toolchain matches. If you’re on an older Node version, upgrade first, since Vite 6 requires Node 18.18+ at minimum and both virtualization libraries assume a modern bundler pipeline with ESM support.

Step 1: Scaffold the React 19 Project

Start with a clean Vite + React + TypeScript template. This gives you React 19.3’s automatic batching and the newer compiler-friendly JSX transform out of the box.

npm create vite@latest react-virtual-demo -- --template react-ts
cd react-virtual-demo
npm install
npm install @tanstack/[email protected]

Open package.json and confirm react and react-dom resolved to 19.3.0 or later. If Vite pulled an older peer version, force it explicitly:

npm install [email protected] [email protected]

Delete the boilerplate in src/App.tsx — you’ll replace it entirely in the next step. Create a src/data.ts file to generate a large mock dataset you’ll reuse across both the React and Vue builds:

// src/data.ts
export interface Row {
  id: number;
  name: string;
  email: string;
  status: "active" | "pending" | "suspended";
}

export function generateRows(count: number): Row[] {
  const statuses: Row["status"][] = ["active", "pending", "suspended"];
  return Array.from({ length: count }, (_, i) => ({
    id: i,
    name: `User ${i.toString().padStart(6, "0")}`,
    email: `user${i}@example.com`,
    status: statuses[i % statuses.length],
  }));
}

Generating 50,000 rows here simulates a realistic admin dashboard, support ticket queue, or transaction log — the exact scenario where virtualization stops being optional. It’s also the same shape of data you’d bind to form inputs in a bulk-edit UI, which is where our React vs Vue forms tutorial picks up once you need validation on top of a dataset this size.

Step 2: Install and Configure @tanstack/react-virtual

@tanstack/react-virtual is a headless hook: it calculates which indexes should be visible and hands you the math, but renders nothing itself. That’s a deliberate design choice from the TanStack team, and it’s why the library’s gzipped bundle is only about 7.5KB despite handling fixed, variable, horizontal, and grid layouts through one API.

The core hook is useVirtualizer. It needs three required inputs: the total row count, a ref to the scrollable container, and a function that estimates row height in pixels.

import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { generateRows } from "./data";

const rows = generateRows(50_000);

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

  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 44,
    overscan: 8,
  });

  return (
    <div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: "relative",
          width: "100%",
        }}
      >
        {virtualizer.getVirtualItems().map((virtualRow) => {
          const row = rows[virtualRow.index];
          return (
            <div
              key={row.id}
              data-index={virtualRow.index}
              ref={virtualizer.measureElement}
              style={{
                position: "absolute",
                top: 0,
                left: 0,
                width: "100%",
                transform: `translateY(${virtualRow.start}px)`,
              }}
            >
              {row.name} — {row.email} — {row.status}
            </div>
          );
        })}
      </div>
    </div>
  );
}

Run npm run dev and open the page. You’ll see the full 50,000-row list scroll smoothly, and if you inspect the DOM in Chrome’s Elements panel, you’ll count roughly 25-35 row elements at any given moment, not 50,000. That’s the entire trick: the outer div reports a height equal to what 50,000 rows would occupy, so the scrollbar behaves correctly, but only the visible slice is ever mounted.

Output Example: Confirming Virtualization Is Actually Working

Before moving on, verify the behavior directly instead of trusting that the code “looks right.” Paste this into your browser console while the page is open, then scroll the list and run it again:

document.querySelectorAll('[data-index]').length
// Expected output: a number between roughly 20 and 40
// This should NOT grow as you scroll further down the list.

You can also confirm the reported total scroll height matches the math you’d expect for 50,000 rows at roughly 44px each:

document.querySelector('[style*="position: relative"]').style.height
// Expected output: "2200000px" (50,000 rows × 44px)

If the first command returns a number that keeps climbing as you scroll (say, 500, then 1,200, then 3,000), the virtualizer isn’t actually recycling rows — check that you passed a real DOM ref to getScrollElement and that the container has a fixed height. If the second command returns NaN or an empty string, getTotalSize() is being called before the virtualizer has calculated row counts, which usually means count was set to 0 or undefined on first render.

Step 3: Build a Fixed-Height Virtualized List in React

The example above already works, but production tables need styling, keyboard focus, and row separators without breaking the absolute-positioning trick. Wrap each row in a component and memoize it so React doesn’t re-render rows that haven’t changed position:

import { memo } from "react";
import type { Row } from "./data";

const StatusPill = memo(function StatusPill({ status }: { status: Row["status"] }) {
  const colors: Record<Row["status"], string> = {
    active: "#16a34a",
    pending: "#ca8a04",
    suspended: "#dc2626",
  };
  return (
    <span style={{ color: colors[status], fontWeight: 600 }}>
      {status.toUpperCase()}
    </span>
  );
});

export const TableRow = memo(function TableRow({ row }: { row: Row }) {
  return (
    <div
      style={{
        display: "flex",
        gap: "16px",
        padding: "10px 16px",
        borderBottom: "1px solid #e5e7eb",
        alignItems: "center",
      }}
    >
      <span style={{ width: 90 }}>{row.id}</span>
      <span style={{ width: 160 }}>{row.name}</span>
      <span style={{ flex: 1 }}>{row.email}</span>
      <StatusPill status={row.status} />
    </div>
  );
});

Swap the inline row markup in App.tsx for <TableRow row={row} />. Because the row is memoized and its props (a single object reference) don’t change between scroll ticks, React skips re-rendering rows that are just being repositioned via CSS transform — the actual work per scroll event stays close to zero.

Step 4: Handle Dynamic Row Heights in React

Fixed 44px rows are the easy case. Real tables often have wrapping text, optional badges, or expandable rows, meaning height isn’t known ahead of time. TanStack Virtual handles this with measureElement, which you already attached as a ref in Step 2 — it uses a ResizeObserver under the hood to measure each row after it mounts and corrects the virtualizer’s internal size cache automatically.

const virtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 56, // rough guess; corrected after first measure
  overscan: 6,
});

// In the row renderer, keep the ref:
<div
  key={row.id}
  data-index={virtualRow.index}
  ref={virtualizer.measureElement}
  style={{
    position: "absolute",
    top: 0,
    transform: `translateY(${virtualRow.start}px)`,
    width: "100%",
  }}
>
  <TableRow row={row} />
</div>

The gotcha here: your estimateSize function still needs to return a reasonable guess. If it’s wildly wrong (say, estimating 20px for rows that render at 200px), the scrollbar will jump around as real measurements come in and correct the total height. Get within 2x of the real average and the correction is imperceptible.

Step 5: Add Horizontal and Grid Virtualization

Wide tables with 40+ columns need the same treatment on the X axis. TanStack Virtual supports this by running two independent virtualizer instances — one for rows, one for columns — inside the same scroll container.

const columnVirtualizer = useVirtualizer({
  horizontal: true,
  count: columns.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 160,
  overscan: 3,
});

const rowVirtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 44,
  overscan: 6,
});

Each cell then gets positioned with both a translateX from the column virtualizer and a translateY from the row virtualizer, so only the intersection of visible rows and visible columns is ever mounted. This is the same pattern behind @tanstack/react-table‘s virtualized examples, and it scales to spreadsheet-sized grids without a separate library. If you’re pairing this with a full data grid, our React vs Vue data tables guide covers the TanStack Table side of that setup in more depth.

The full useVirtualizer configuration object accepts more options than the examples above show. Here’s the reference for the ones you’ll actually reach for in production, and they’re identical across the React and Vue adapters since both wrap the same core package:

OptionTypeRequiredWhat it controls
countnumberYesTotal number of items in the full (non-visible) dataset
getScrollElement() => Element | nullYesReturns the scrollable container the virtualizer should measure and listen to
estimateSize(index: number) => numberYesInitial height/width guess per item before real measurement, can vary by index
overscannumberNo (default 1)Extra items rendered above/below the visible window to prevent blank flashes
horizontalbooleanNo (default false)Switches the virtualizer to measure and scroll along the X axis instead of Y
measureElementref callbackNoAttaches a ResizeObserver to each row to correct estimated sizes with real measurements
paddingStart / paddingEndnumberNoReserves extra space before the first or after the last item, useful for sticky headers
scrollMarginnumberNoOffsets the virtualizer’s scroll calculations when the container isn’t the direct scroll parent

Two of these deserve extra attention because they’re where most real-world configuration mistakes happen. overscan is a tradeoff, not a “bigger is always better” dial — every extra item you overscan is an item that’s mounted and measured even when the user never sees it, so cranking it to 50 to “be safe” defeats a meaningful chunk of the performance benefit you’re trying to get. And scrollMargin only matters once your virtualized list is one section inside a longer scrolling page rather than owning its own scroll container — it’s easy to skip until you refactor a standalone demo into a real page layout and the row positions suddenly seem to drift by a fixed offset.

Step 6: Scaffold the Vue 3.5 Project

Switch to the Vue side. Scaffold a fresh Vite + Vue + TypeScript project and pull in both virtualization approaches you’ll compare — the TanStack adapter and vue-virtual-scroller.

npm create vite@latest vue-virtual-demo -- --template vue-ts
cd vue-virtual-demo
npm install
npm install @tanstack/[email protected]
npm install [email protected]

Confirm Vue resolved to 3.5.42 or later with npm ls vue. Vue 3.5’s compiler ships reactive-array tracking optimizations that make even non-virtualized v-for loops faster on large arrays than in Vue 3.4, but that speedup doesn’t remove the fundamental DOM-node-count problem — it just delays when you notice it.

Reuse the same Row shape and mock data generator from the React project so the two builds are directly comparable:

// src/data.ts
export interface Row {
  id: number;
  name: string;
  email: string;
  status: "active" | "pending" | "suspended";
}

export function generateRows(count: number): Row[] {
  const statuses: Row["status"][] = ["active", "pending", "suspended"];
  return Array.from({ length: count }, (_, i) => ({
    id: i,
    name: `User ${i.toString().padStart(6, "0")}`,
    email: `user${i}@example.com`,
    status: statuses[i % statuses.length],
  }));
}

Step 7: Install and Configure @tanstack/vue-virtual

@tanstack/vue-virtual shares its core engine with the React adapter — same overscan logic, same measurement strategy — but it’s exposed as a composable, useVirtualizer, that returns refs and computed values instead of hook state.

<script setup lang="ts">
import { ref } from "vue";
import { useVirtualizer } from "@tanstack/vue-virtual";
import { generateRows } from "./data";

const rows = generateRows(50_000);
const parentRef = ref<HTMLDivElement | null>(null);

const virtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => parentRef.value,
  estimateSize: () => 44,
  overscan: 8,
});
</script>

<template>
  <div ref="parentRef" style="height: 600px; overflow: auto;">
    <div
      :style="{
        height: `${virtualizer.getTotalSize()}px`,
        position: 'relative',
        width: '100%',
      }"
    >
      <div
        v-for="virtualRow in virtualizer.getVirtualItems()"
        :key="rows[virtualRow.index].id"
        :data-index="virtualRow.index"
        :ref="virtualizer.measureElement"
        :style="{
          position: 'absolute',
          top: 0,
          left: 0,
          width: '100%',
          transform: `translateY(${virtualRow.start}px)`,
        }"
      >
        {{ rows[virtualRow.index].name }} — {{ rows[virtualRow.index].email }}
      </div>
    </div>
  </div>
</template>

Run npm run dev. Behaviorally this is identical to the React version — same overscan, same absolute-positioning strategy — but notice that Vue’s useVirtualizer returns a raw object rather than a value you need to unwrap through .value for method calls like getVirtualItems(). That’s a deliberate ergonomic choice by the TanStack Vue adapter to keep template syntax close to the React version.

Step 8: Build the Same List with Vue Composables

Extract the row markup into its own component the way you did in React, and pull the virtualizer setup into a reusable composable so multiple lists in your app can share the same configuration:

// src/composables/useRowVirtualizer.ts
import { type Ref } from "vue";
import { useVirtualizer } from "@tanstack/vue-virtual";

export function useRowVirtualizer(
  count: number,
  scrollRef: Ref<HTMLElement | null>
) {
  return useVirtualizer({
    count,
    getScrollElement: () => scrollRef.value,
    estimateSize: () => 44,
    overscan: 8,
  });
}
<!-- src/components/StatusPill.vue -->
<script setup lang="ts">
import { computed } from "vue";
import type { Row } from "../data";

const props = defineProps<{ status: Row["status"] }>();

const color = computed(() => ({
  active: "#16a34a",
  pending: "#ca8a04",
  suspended: "#dc2626",
}[props.status]));
</script>

<template>
  <span :style="{ color, fontWeight: 600 }">{{ status.toUpperCase() }}</span>
</template>

Vue’s fine-grained reactivity means a StatusPill only re-renders when its own status prop actually changes, without needing an explicit memo() wrapper the way React does. This is one of the clearest DX differences in this whole comparison: Vue’s compiler tracks dependencies automatically per component, while React relies on you to opt in to memoization.

Step 9: Compare vue-virtual-scroller’s RecycleScroller Approach

Where TanStack Virtual is headless, vue-virtual-scroller ships a ready-made component, RecycleScroller, that owns the scroll container and the DOM recycling logic for you. It’s a Vue-native library (not a cross-framework port), it has 10,796 GitHub stars as of this writing, and its gzipped bundle is roughly 16.5KB — more than double TanStack Virtual’s, because it bundles its own scroll-container markup and slot system instead of staying headless.

<script setup lang="ts">
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import { generateRows } from "./data";

const rows = generateRows(50_000);
</script>

<template>
  <RecycleScroller
    class="scroller"
    :items="rows"
    :item-size="44"
    key-field="id"
    style="height: 600px;"
    v-slot="{ item }"
  >
    <div class="row">{{ item.name }} — {{ item.email }} — {{ item.status }}</div>
  </RecycleScroller>
</template>

Notice how much shorter this is than the TanStack version — RecycleScroller handles the spacer div, the absolute positioning, and the scroll listener internally. The tradeoff is control: you’re locked into its slot API and its own CSS class names, and if you need horizontal-plus-vertical grid virtualization like Step 5’s React example, vue-virtual-scroller doesn’t offer it out of the box. For a straightforward single-column list, though, it gets you there in fewer lines than either TanStack adapter. If your row markup already leans on a shared component system, our shadcn/ui vs shadcn-vue setup guide shows how to keep that styling consistent across both frameworks.

Step 10: Wire Up Infinite Scroll and Data Fetching

Real dashboards rarely load 50,000 rows up front — they paginate from an API and fetch more as the user scrolls. Both virtualizer APIs expose the last visible index, which is exactly what you need to trigger the next page fetch.

// React: fetch more when the user nears the end of the loaded data
const virtualItems = virtualizer.getVirtualItems();
const lastItem = virtualItems[virtualItems.length - 1];

useEffect(() => {
  if (!lastItem) return;
  if (lastItem.index >= rows.length - 20 && hasNextPage && !isFetching) {
    fetchNextPage();
  }
}, [lastItem, rows.length, hasNextPage, isFetching]);
// Vue: the same check, expressed with watchEffect
watchEffect(() => {
  const items = virtualizer.getVirtualItems();
  const last = items[items.length - 1];
  if (last && last.index >= rows.value.length - 20 && hasNextPage.value && !isFetching.value) {
    fetchNextPage();
  }
});

In a React app this pairs naturally with TanStack Query’s useInfiniteQuery; in Vue, with Pinia Colada or TanStack Query’s Vue adapter. Either way, the virtualizer only cares about indexes, not where the data came from, so it’s agnostic to your fetching layer.

Step 11: Profile Performance in Chrome DevTools

Don’t take the “it’s smooth now” feeling on faith — measure it. Open Chrome DevTools, go to the Performance panel, and record a scroll session on both the virtualized and non-virtualized versions of the same 50,000-row list.

  1. Open DevTools (F12) and switch to the Performance tab.
  2. Check “Screenshots” and set CPU throttling to 4x slowdown to simulate a mid-range device.
  3. Click Record, scroll the list continuously for five seconds, then stop.
  4. Look at the frame rate chart at the top: virtualized lists should hold close to 60fps (green bars), while an unvirtualized 50,000-row list will show long red “Long Task” blocks and dropped frames.
  5. Open the Memory tab and take a heap snapshot before and after scrolling — a properly virtualized list’s heap should stay flat, since old row nodes are discarded, not accumulated.

You’ll also want to check the Elements panel while scrolling. Right-click the row container and choose “Scroll into view,” then watch the child count in the DOM tree — it should hover in the 20-40 range for a 600px viewport with 44px rows and an overscan of 8, regardless of whether the underlying array has 500 or 500,000 items.

Step 12: Production Build and Deployment Checklist

Before shipping either version, run through this checklist — virtualized lists have production failure modes that don’t show up in local dev with a fast machine and a warm cache.

  • Run npm run build for both projects and confirm the virtualizer chunk is code-split if it’s only used on one route.
  • Test on a throttled connection and a mid-tier Android device, not just your dev laptop.
  • Verify keyboard navigation (Tab, arrow keys) still reaches rows that are currently virtualized out of the DOM — this is the single most common accessibility regression from adding virtualization.
  • Confirm your row key (React) or key-field (Vue) is a stable unique ID, never the array index, or scroll position will corrupt on data updates.
  • Set overscan high enough to avoid blank flashes on fast scroll (8-12 is a reasonable starting point) but not so high it defeats the purpose of virtualizing at all.
  • Test browser back/forward navigation — restoring scroll position in a virtualized list requires explicitly re-scrolling to a saved index, since the browser’s native scroll restoration doesn’t know about your virtual content.

React vs Vue Virtualization Libraries: Full Comparison Table

Here’s how the five libraries covered in this tutorial stack up on real, current numbers pulled from the npm registry and GitHub’s API this week.

LibraryFrameworkLatest versionWeekly npm downloadsGitHub starsGzipped size
@tanstack/react-virtualReact3.14.12~17,214,6387,104 (TanStack/virtual repo)~7.5KB
@tanstack/vue-virtualVue3.13.38~2,590,3557,104 (shared repo)~7.5KB (same core)
react-windowReact2.3.1~4,664,65917,204~4.4KB
react-virtuosoReact4.18.13~2,410,6616,453~18.8KB
vue-virtual-scrollerVue3.0.5~359,87410,796~16.5KB

Two things stand out. First, the download gap between the React and Vue adapters of the exact same TanStack Virtual engine is roughly 6.6x — not because the underlying code differs, but because it reflects the broader React-vs-Vue install base for this category of tooling. Second, react-window is the smallest package here by gzip size but also the oldest and least actively evolved API of the group; react-virtuoso trades bundle size for a much richer feature set (grouping, sticky headers, table mode) built in rather than hand-rolled.

Common Pitfalls When Virtualizing Lists

These five mistakes account for most of the “why is my virtualized list broken” bug reports on both TanStack Virtual’s and vue-virtual-scroller’s GitHub issue trackers. Most of them share a root cause: virtualization only works correctly when the browser’s actual rendered geometry matches what the virtualizer thinks it calculated, and small assumptions that are harmless in a normal list become visible bugs the moment rows start getting mounted and unmounted on the fly.

  1. Using array index as the key. When rows are inserted, removed, or filtered, an index-based key causes the virtualizer to reuse the wrong measured height for the wrong row, producing visible jump-and-settle glitches on scroll.
  2. Forgetting position: relative on the inner wrapper. The absolute-positioned rows need a positioned ancestor at the right level, or they’ll position relative to the nearest positioned parent further up the tree — usually the whole page.
  3. Setting the scroll container’s height with content-based sizing (like height: auto). Virtualization requires a fixed or bounded height on the scroll container; without it, the browser tries to grow the container to fit all rows, defeating the entire optimization.
  4. Mismatched estimateSize for highly variable rows. If some rows are 40px and others are 400px, a flat estimateSize: () => 44 will cause large scrollbar jumps as measurements correct. Group similar-height rows or provide a smarter estimate function based on row content type.
  5. Skipping the overscan tuning entirely. Zero overscan causes visible blank flashes at the top and bottom edges during fast scroll or keyboard paging, since new rows haven’t finished rendering before they need to be visible.

Troubleshooting Guide

Work through these in order when a virtualized list misbehaves — they’re ranked roughly by how often they come up in practice. Before diving into any individual fix, open the console and check for warnings first; both TanStack Virtual and vue-virtual-scroller log descriptive warnings in development mode for most of the misconfigurations below, and reading them saves a debugging session.

  1. List appears empty or shows zero rows. Check that getScrollElement returns a non-null ref after mount — on the very first render the ref is often still null, and the virtualizer needs to re-run once it resolves.
  2. Scrollbar height doesn’t match the data. Confirm the outer spacer div’s height is bound to virtualizer.getTotalSize() and that this value updates reactively (a computed in Vue, not a snapshot taken once).
  3. Rows flicker or duplicate briefly during fast scrolling. Increase overscan from the default of 1 up to 6-10, and confirm your row component doesn’t have expensive synchronous work in its render path.
  4. Dynamic-height rows never stabilize. Make sure every row element has the measureElement ref attached directly to the outermost element of that row, not a nested child.
  5. Keyboard focus jumps to the wrong element after scrolling. This happens because virtualized rows are unmounted and remounted; store focus intent by row ID, not by DOM node reference, and restore it after the target row re-renders.
  6. Horizontal virtualization cells misalign with headers. Verify the header row uses the same column virtualizer instance (or the same width calculations) as the body — a common bug is instantiating two separate column virtualizers with different overscan settings.
  7. Vue’s RecycleScroller shows console warnings about a missing key-field. Every item in the :items array needs a unique property matching whatever string you pass to key-field — a plain array of primitives won’t work without wrapping each value in an object.
  8. Performance is still poor despite virtualization. Profile with DevTools before assuming the virtualizer is at fault — often the real cost is in an unmemoized row component doing expensive formatting (like date parsing) on every render, not in the virtualization math itself.

Advanced Tips: Sticky Headers, SSR, and Accessibility

Sticky headers on a virtualized table need to live outside the scrolling virtualizer content, not inside it — pin the header row with position: sticky; top: 0 on a separate element above the virtualized body, rather than trying to make the virtualizer itself understand “this one row never scrolls.”

Server-side rendering is where the two ecosystems diverge most. React Server Components can render the shell of a virtualized table on the server, but the actual virtualization math depends on real DOM measurements (container height, scroll position) that only exist client-side — so plan to render a reasonable static “first page” of rows during SSR and hydrate the virtualizer on the client. Vue’s Nuxt has the same constraint; @tanstack/vue-virtual explicitly recommends deferring virtualizer initialization until after mount in SSR contexts. If you’re deciding between meta-frameworks for a data-heavy app in the first place, our Astro vs Next.js vs Nuxt comparison covers how each one handles this client/server split.

For accessibility, add role="grid" or role="listbox" to the scroll container and aria-rowindex to each row reflecting its true position in the full dataset, not its position in the currently mounted DOM slice — screen readers need to announce “row 4,502 of 50,000,” not “row 12 of 30.” Both TanStack Virtual and vue-virtual-scroller expose the real index on every virtual item specifically to support this pattern. The IntersectionObserver API that underlies most measurement strategies here is also worth understanding directly if you’re debugging edge cases.

One more advanced pattern worth knowing: bidirectional infinite lists, where users can scroll both up and down to load older and newer data (common in chat apps and activity feeds). This requires tracking a separate offset for “how many unloaded items exist above the current window” and adjusting scrollMargin or the total count dynamically as older pages load in, since inserting rows above the current scroll position would otherwise cause a visible jump. Both TanStack Virtual’s docs and vue-virtual-scroller’s DynamicScroller component include dedicated examples for this exact case — it’s common enough in production chat and social-feed UIs that neither library treats it as a fringe scenario.

Complete Working Project: Full Source Code

Here’s the complete, runnable React version combining everything from Steps 1-4 into a single file you can drop into a fresh Vite project. See the official TanStack Virtual documentation for the full API reference this example builds on.

// src/App.tsx — complete working example
import { useRef, memo } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { generateRows, type Row } from "./data";

const rows = generateRows(50_000);

const TableRow = memo(function TableRow({ row }: { row: Row }) {
  const colors: Record<Row["status"], string> = {
    active: "#16a34a",
    pending: "#ca8a04",
    suspended: "#dc2626",
  };
  return (
    <div style={{ display: "flex", gap: 16, padding: "10px 16px", borderBottom: "1px solid #e5e7eb" }}>
      <span style={{ width: 90 }}>{row.id}</span>
      <span style={{ width: 160 }}>{row.name}</span>
      <span style={{ flex: 1 }}>{row.email}</span>
      <span style={{ color: colors[row.status], fontWeight: 600 }}>{row.status.toUpperCase()}</span>
    </div>
  );
});

export default function App() {
  const parentRef = useRef<HTMLDivElement>(null);
  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 44,
    overscan: 8,
  });

  return (
    <div ref={parentRef} style={{ height: 600, overflow: "auto", border: "1px solid #d1d5db" }}>
      <div style={{ height: virtualizer.getTotalSize(), position: "relative", width: "100%" }}>
        {virtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={rows[virtualRow.index].id}
            ref={virtualizer.measureElement}
            data-index={virtualRow.index}
            style={{ position: "absolute", top: 0, left: 0, width: "100%", transform: `translateY(${virtualRow.start}px)` }}
          >
            <TableRow row={rows[virtualRow.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

Running this and scrolling to the bottom of a 50,000-row list, you should see IDs climbing toward 49,999 with no stutter, and the browser’s task manager reporting stable memory use rather than a steady climb. That output — a flat memory line and a consistently green frame-rate graph in DevTools — is the practical proof that virtualization is doing its job, in either framework. The same principles apply to the Vue build from Step 7 through Step 9; both are available in full in the TanStack Virtual GitHub repository‘s examples directory, alongside the vue-virtual-scroller source for the RecycleScroller-based alternative.

Frequently Asked Questions

Do I need list virtualization for every table in my app?
No. Below roughly 200-300 rows, the DOM overhead is negligible on modern hardware, and adding a virtualizer introduces complexity (measurement quirks, accessibility work, harder SSR) that isn’t worth it. Reach for virtualization once a list or table can realistically grow past a few hundred visible rows.

Is @tanstack/vue-virtual as mature as the React version?
They share the same core virtualization engine and release cadence from the TanStack team, so the underlying math and feature set (fixed, dynamic, horizontal, grid) are identical. The gap is purely in ecosystem size — roughly 6.6x fewer weekly downloads for the Vue adapter — not in feature completeness.

Can I use react-window and TanStack Virtual in the same project?
Technically yes, since they don’t conflict at the code level, but there’s little reason to. Pick one based on whether you need react-window’s smaller footprint for simple fixed-size lists or TanStack Virtual’s headless flexibility for dynamic and grid layouts, and standardize on it across the codebase.

Why does my virtualized list break when I filter or sort the data?
This is almost always the index-as-key pitfall from earlier in this guide. When the underlying array reorders, index-keyed rows get matched to the wrong measured heights and cached state. Switch to a stable, unique id field as the key and the issue disappears.

Does virtualization work with CSS Grid or table elements instead of divs?
Yes, but native HTML table elements are trickier because you can’t easily apply position: absolute to a tr without breaking table layout rules. Most production implementations use CSS Grid or flexbox rows styled to look like a table (as shown in this tutorial’s TableRow component) rather than real table markup.

What’s the practical overscan value for most apps?
Start at 6-8 rows above and below the visible viewport. This absorbs the render latency for fast scroll wheel or trackpad gestures without meaningfully increasing the DOM node count. Go higher (10-15) only if you see blank flashes on very fast scroll after profiling.

Does React 19.3 or Vue 3.5 change how virtualization works?
Neither release changes the fundamentals. React 19.3, released September 9, 2026, adds View Transitions and Fragment Refs that can smooth navigation between virtualized views, according to the official React blog. Vue 3.5’s compiler-level array-tracking optimizations, detailed on the Vue.js blog, make the non-virtualized baseline faster — but once your dataset exceeds a few hundred rows, you still need actual virtualization; compiler improvements reduce overhead, they don’t eliminate the DOM node count problem.

Which library should I pick for a brand-new project in 2026?
For React, @tanstack/react-virtual is the safer long-term bet given its download volume and headless flexibility, unless you specifically want react-virtuoso’s built-in grouping and sticky-header features and don’t mind the larger bundle. For Vue, use @tanstack/vue-virtual if you want feature parity with a React counterpart (useful in mixed-framework teams), or vue-virtual-scroller if you want less boilerplate for a simple single-column list and don’t need grid virtualization. Vue’s own performance best-practices guide covers when virtualization is worth the added complexity in more general terms.

Related Coverage

Sofia Lindström

Sofia Lindström

Editor-in-Chief

Sofia Lindström is the Editor-in-Chief at Tech Insider, where she leads editorial strategy and oversees coverage across AI, cybersecurity, and enterprise technology. With over a decade in Swedish tech journalism, she previously served as technology editor at Dagens Industri and covered the Nordic startup ecosystem for Breakit. Sofia holds an MSc in Media Technology from KTH Royal Institute of Technology and is a frequent speaker at Web Summit and Slush. She is passionate about making complex technology accessible to business leaders.

View all articles