DEV Community

Cover image for List Virtualization — How to Render 100,000 Rows Without Killing Your Browser
YT Low
YT Low

Posted on Originally published at onlywebpro.com

List Virtualization — How to Render 100,000 Rows Without Killing Your Browser

Full guide with a live 100,000-row demo, step-by-step code, and library comparison on onlyWebPro.

Throw 50,000 <div>s at a browser and watch it choke.

A list of 50,000 items with no virtualization: ~850MB of DOM. The same list with virtualization: ~1.2MB. Same data. Same scroll experience. Completely different DOM cost.

Here's how the technique works — in 7 steps, plain JavaScript, no library.


The core idea

Only create real DOM elements for the rows the user can actually see — typically 15–20 at any time. Everything else stays as plain JavaScript data. As the user scrolls, you reuse those same elements, update their content, and reposition them. No creating or destroying thousands of nodes.


The 7 steps

Step 1 — Scrollable container
A fixed-height div with overflow-y: auto and position: relative. This is the viewport the user scrolls inside.

Step 2 — The spacer (the non-obvious one)
If you only put 15 real rows inside the container, the scrollbar thinks the list is tiny. Fix: add one invisible div whose height equals total items × row height. It takes up space without rendering anything. The scrollbar now correctly represents 50,000 rows.

Step 3 — The maths
scrollTop / ITEM_HEIGHT tells you which row is at the top of the screen. containerHeight / ITEM_HEIGHT tells you how many rows fit. That's your visible range.

function getVisibleRange(scrollTop, containerHeight) {
  const startIndex   = Math.floor(scrollTop / ITEM_HEIGHT);
  const visibleCount = Math.ceil(containerHeight / ITEM_HEIGHT);
  const endIndex     = startIndex + visibleCount;
  return { startIndex, endIndex };
}
Enter fullscreen mode Exit fullscreen mode

Step 4 — Render only the visible slice
Loop from startIndex to endIndex only — not across all 50,000 items.

Step 5 — Absolute positioning (the other non-obvious one)
If row #20,000 is the first one you render, the browser puts it at the top of the container by default. Wrong. Fix: top: index × ITEM_HEIGHT. Every row positions itself exactly where it would be if everything above it were rendered.

row.style.position = 'absolute';
row.style.top      = (i * ITEM_HEIGHT) + 'px';
Enter fullscreen mode Exit fullscreen mode

Step 6 — Re-render on scroll
One scroll listener wrapped in requestAnimationFrame. Recalculate, re-render.

Step 7 — Overscan
Render 3 extra rows above and below the visible area as a buffer. Fast scrolls won't outrun your rendering.

const OVERSCAN = 3;

const startIndex = Math.floor(scrollTop / ITEM_HEIGHT) - OVERSCAN;
const endIndex   = startIndex + visibleCount + OVERSCAN * 2;
Enter fullscreen mode Exit fullscreen mode

When to use a library instead

The technique above is what every virtualization library builds on. But for production apps, you'll likely want:

  • TanStack Virtual — framework-agnostic, handles variable heights, grids, horizontal lists. Best default in 2026.
  • react-window — lightweight, React-only, FixedSizeList maps directly to what we built here.
  • react-virtuoso — auto-measured heights, most "batteries included" option.

Build from scratch when you need zero dependencies, full rendering control, or want to understand what the library is doing under the hood.


When you don't need this

  • Under 100 items → just render normally
  • 100–500 items → consider pagination first
  • 500+ items with continuous scroll → virtualization is the right tool
  • Chat history, logs, activity feeds → classic use case, always virtualize

The full guide on onlyWebPro has a live 100,000-row demo you can scroll while inspecting the DOM in DevTools — the node count stays at ~18 no matter how far you scroll.

👉 Read the full guide + live demo


What's your go-to approach for large lists — roll your own or reach for a library?

Top comments (0)