DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

How I Render 100,000 Rows With Only ~20 DOM Nodes

Scroll a list of a hundred thousand items the naive way — list.appendChild(row) a hundred thousand times — and your tab freezes for seconds, eats hundreds of megabytes, and stutters forever after. Yet Slack scrolls years of messages, spreadsheets hold a million cells, and infinite feeds never slow down. They all use the same trick, and it's about 80 lines of vanilla JavaScript.

It's called virtual scrolling (or windowing). Here's the whole idea, built from scratch.

Try it live: https://dev48v.infy.uk/design/day21-virtual-scroll.html

Why the browser can't just render them all

Every DOM node costs memory, style resolution, layout, and paint. A hundred thousand rows means a hundred thousand of each of those before a single pixel appears. The browser is fast at rendering what fits on screen, not at holding an entire novel of hidden elements. The moment your list is bigger than a screenful, brute force stops being an option.

The demo has a "Render all (naive)" button — hit it and feel the hitch, watch the DOM-node counter jump into the tens of thousands. Toggle back to "Virtual" and it's instantly smooth, the counter flat at around 20.

The one mental shift: data is not the DOM

Your list has 100,000 entries of data. The box only ever shows a dozen. Virtual scrolling makes the DOM match what the user can see, not what the data contains.

const data = Array.from({length: 100000}, (_, i) => "Row " + i);
// ↑ huge and cheap — just JS memory, zero elements
Enter fullscreen mode Exit fullscreen mode

Keep the full dataset in a plain array; mount only the visible window as real elements. Node count is bounded by the viewport, not the data.

The viewport + a tall empty spacer

Two elements. A fixed-height scroll box, and inside it a single empty "sizer" whose height equals all the rows:

const ROW_H = 36, TOTAL = 100000;
sizer.style.height = (ROW_H * TOTAL) + "px";   // 3,600,000px tall, and empty
Enter fullscreen mode Exit fullscreen mode

That one number gives the scrollbar the correct size and range — it behaves exactly as if all 100k rows were really there. The real rows get absolutely positioned inside it:

.vs-viewport { position: relative; height: 420px; overflow-y: auto; }
.vs-sizer    { position: relative; }
.vs-row      { position: absolute; left: 0; right: 0; height: 36px; }
Enter fullscreen mode Exit fullscreen mode

The scrollbar tracks the data; the DOM tracks the window.

Which rows are visible? Pure arithmetic

With a fixed row height, the link between scroll position and data index is exact — no measuring, no guessing:

const scrollTop = viewport.scrollTop;
const first = Math.floor(scrollTop / ROW_H);            // first visible index
const count = Math.ceil(viewport.clientHeight / ROW_H); // rows that fit
const last  = first + count;
Enter fullscreen mode Exit fullscreen mode

Scrolled down 1,800px with 36px rows? Math.floor(1800 / 36) = row 50 is at the top. That's it. This is why fixed-height windowing is the easy case to build first.

Render the window, positioned by index

Each row lands at the exact pixel it would occupy in the full list:

function render() {
  const from = Math.max(0, first - OVERSCAN);
  const to   = Math.min(TOTAL - 1, last + OVERSCAN);
  const frag = document.createDocumentFragment();
  for (let i = from; i <= to; i++) {
    const row = document.createElement("div");
    row.className = "vs-row";
    row.style.top = (i * ROW_H) + "px";   // absolute position by index
    row.textContent = "Row " + i;
    frag.appendChild(row);
  }
  sizer.replaceChildren(frag);
}
Enter fullscreen mode Exit fullscreen mode

The overscan buffer

Render exactly the visible rows and a fast flick can paint a scroll frame before your handler mounts the newly-exposed rows — you get a blank strip at the leading edge. The fix is overscan: render a few extra rows above and below the window.

const OVERSCAN = 5;   // extra rows each side, already off-screen and ready
Enter fullscreen mode Exit fullscreen mode

Those buffer rows already exist, so nudging them into view creates nothing and shows no gap. Five rows barely changes the node count but makes scrolling feel solid.

Two cheap tricks that keep it fast

Throttle with rAF. The scroll event fires dozens of times per frame. Collapse a burst into one render per frame:

let ticking = false;
viewport.addEventListener("scroll", () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => { render(); ticking = false; });
});
Enter fullscreen mode Exit fullscreen mode

Skip when the window hasn't moved. Most scroll pixels don't change which rows show — scrolling 10px inside a 36px row shows the same set, and the browser shifts them for free. So bail early:

let lastFrom = -1;
function render() {
  const from = /* … */;
  if (from === lastFrom) return;   // window unchanged → zero DOM work
  lastFrom = from;
  /* …rebuild only on a row-boundary cross… */
}
Enter fullscreen mode Exit fullscreen mode

Cheap comparisons guarding expensive DOM writes — that's the pattern.

The parts that get hard

Recycling nodes (advanced): instead of rebuilding, keep a fixed pool of ~20 elements and re-point each one's top and text as the window moves. No create/destroy means no GC churn — this is what serious libraries do.

Variable row heights (advanced): the moment rows differ, scrollTop / rowHeight dies — you can't know a row's top without every row above it. You measure heights as they render, keep a cumulative-offset array, and binary-search scrollTop into an index, estimating unmeasured rows. That bookkeeping is exactly why TanStack Virtual and react-window exist.

Accessibility is the real tax: only ~20 rows exist, so Ctrl-F, screen readers, and Tab order see only those. Declare the true shape with aria-rowcount="100000" and aria-rowindex per row, never virtualize a list small enough to render whole, and show a plain-text result count nearby.

The takeaway

A big list is just data. Keep the DOM the size of the window, give the scrollbar a spacer the size of the data, position rows by index × rowHeight, add a small overscan, and re-render on scroll — skipping when the window hasn't moved. That's the engine behind every chat log, spreadsheet, and infinite feed you use. Open the demo and scroll to row 100,000 — the node counter never moves.

Top comments (0)