Back to Blog
Virtual Scrolling: Taming the Million-Row Beast Without Crushing the Browser
7 min readJun 19, 20267 views

Virtual Scrolling: Taming the Million-Row Beast Without Crushing the Browser

Rendering massive datasets in the browser usually means a sluggish UI, memory hogs, and frustrated users. Virtual scrolling is the answer, but getting it right is harder than it looks. Let's dive into how to build a robust virtual scroller that handles millions of rows with ease.

FrontendPerformanceUIWeb Development
Share

by Sunil Band

The Million-Row Problem

We've all been there: a client wants to display all their data in a single table, and "all" means hundreds of thousands, sometimes millions, of rows. Your initial reaction is probably a mix of dread and resignation. You know that dumping that much DOM into the browser will bring it to its knees, leading to slow rendering, memory leaks, and a user experience so bad it's practically unusable. The browser wasn't designed to render an entire database table at once.

So what do you do? Pagination helps, but often users demand the ability to scroll through everything. Infinite scrolling is a step better, fetching more data as you approach the bottom, but it still eventually hits the same wall: too many DOM nodes. The real challenge is showing a subset of a massive list dynamically based on scroll position, without actually rendering the invisible parts.

This is where virtual scrolling comes in. It's the technique of rendering only the items currently visible within the viewport, plus a small buffer, while maintaining the illusion of a continuous, scrollable list. It's not magic, but it feels like it when you get it right. It allows you to display truly enormous datasets without breaking the browser or your users' patience.

How Virtual Scrolling Works: The Core Idea

The fundamental principle behind virtual scrolling is simple: decoupling the scrollable height from the actual rendered content. Instead of rendering all N items and letting the browser calculate the scroll height, we:

  1. Calculate the total theoretical height of all N items as if they were all rendered.
  2. Create a container element with this calculated height to act as our scrollable canvas.
  3. Render only a small window of items that are currently visible within the viewport.
  4. Dynamically adjust the transform (or top style) of the rendered items' container to simulate their correct position within the full scrollable space.

This means that no matter if you have 100 items or 10 million, you only ever have a few dozen (or a few hundred, depending on buffer) actual DOM elements in memory. The performance gains are immediate and dramatic.

The Math Behind the Illusion

Let's break down the key pieces of information we need to manage:

  • itemHeight: The height of a single item. For simplicity, we'll assume fixed height items for now. Variable height items introduce more complexity, which we'll touch on later.
  • totalItems: The total number of items in our dataset.
  • viewportHeight: The height of the scrollable container.
  • scrollTop: The current vertical scroll position of the container.

From these, we can calculate everything else:

  • totalScrollHeight = totalItems * itemHeight
  • startIndex = floor(scrollTop / itemHeight)
  • endIndex = ceil((scrollTop + viewportHeight) / itemHeight)
  • renderedItems = items.slice(startIndex, endIndex + buffer) (we add a buffer to prevent flickering during fast scrolls)
  • offset = startIndex * itemHeight (this is the transformY or top for our rendered window)

The offset is crucial. It shifts our small window of rendered items down the page, making it appear as if they are part of a much larger list. As the user scrolls, scrollTop changes, which updates startIndex, endIndex, and offset, causing a new window of items to be rendered and positioned.

Building a Basic Virtual Scroller in React

Let's put this into practice with a simplified React component. We'll use a div as our scrollable container and some state to manage the visible range.

typescript
import React, { useRef, useState, useEffect, useCallback } from 'react';

interface VirtualizedListProps<T> {
  items: T[];
  itemHeight: number; // For now, assume fixed height items
  renderItem: (item: T, index: number) => React.ReactNode;
  buffer?: number; // Number of items to render above/below viewport
}

function VirtualizedList<T>({ items, itemHeight, renderItem, buffer = 5 }: VirtualizedListProps<T>) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [scrollTop, setScrollTop] = useState(0);
  const [viewportHeight, setViewportHeight] = useState(0);

  const totalItems = items.length;
  const totalScrollHeight = totalItems * itemHeight;

  useEffect(() => {
    // Initialize viewportHeight on mount
    if (containerRef.current) {
      setViewportHeight(containerRef.current.clientHeight);
    }

    // Add resize observer for responsive viewport height
    const observer = new ResizeObserver(entries => {
      if (entries[0]) {
        setViewportHeight(entries[0].contentRect.height);
      }
    });
    if (containerRef.current) {
      observer.observe(containerRef.current);
    }
    return () => observer.disconnect();
  }, []);

  const handleScroll = useCallback(() => {
    if (containerRef.current) {
      setScrollTop(containerRef.current.scrollTop);
    }
  }, []);

  useEffect(() => {
    const container = containerRef.current;
    if (container) {
      container.addEventListener('scroll', handleScroll);
      return () => container.removeEventListener('scroll', handleScroll);
    }
  }, [handleScroll]);

  // Calculate visible range
  const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer);
  const endIndex = Math.min(totalItems - 1, Math.ceil((scrollTop + viewportHeight) / itemHeight) + buffer);

  // Slice items to render and calculate offset
  const visibleItems = items.slice(startIndex, endIndex + 1); // +1 because slice end is exclusive
  const offset = startIndex * itemHeight;

  return (
    <div
      ref={containerRef}
      style={{
        height: '100%', // Or any fixed height
        overflowY: 'auto',
        position: 'relative',
        willChange: 'transform' // Performance hint for browser
      }}
    >
      <div
        style={{
          height: totalScrollHeight, // This creates the scrollable space
          position: 'relative'
        }}
      >
        <div
          style={{
            transform: `translateY(${offset}px)`,
            position: 'absolute',
            top: 0,
            left: 0,
            right: 0,
            willChange: 'transform' // Another performance hint
          }}
        >
          {visibleItems.map((item, index) => renderItem(item, startIndex + index))}
        </div>
      </div>
    </div>
  );
}

export default VirtualizedList;

// Example Usage:
/*
function App() {
  const data = Array.from({ length: 1000000 }, (_, i) => ({ id: i, name: `Item ${i}` }));

  const renderRow = (item: { id: number; name: string }) => (
    <div key={item.id} style={{ height: 30, padding: 5, borderBottom: '1px solid #eee' }}>
      {item.id}: {item.name}
    </div>
  );

  return (
    <div style={{ height: '600px', width: '400px', border: '1px solid #ccc' }}>
      <VirtualizedList items={data} itemHeight={30} renderItem={renderRow} />
    </div>
  );
}
*/

In this example:

  • containerRef points to our scrollable div. We use a ResizeObserver to react to changes in its height, ensuring our calculations stay accurate.
  • scrollTop and viewportHeight are state variables that trigger re-renders when the scroll position or container size changes.
  • totalScrollHeight sets the actual height of an inner div, which is what the browser uses to determine the total scrollable area.
  • The transform: translateY(${offset}px) on the visibleItems container is the key. It moves our small window of rendered components to the correct visual position within the totalScrollHeight.
  • The buffer prop is important. Without it, items would pop in and out abruptly at the exact edge of the viewport. A small buffer makes scrolling feel much smoother.

Even with a million items in the data array, this component will only ever render a handful of divs, providing incredibly smooth performance.

Variable Item Heights: The Next Frontier

Fixed item height is a nice simplification, but real-world data often comes with variable heights. Think multiline text, images, or dynamically sized components. Handling this efficiently is the real trick in advanced virtual scrolling.

When item heights vary, our simple startIndex = floor(scrollTop / itemHeight) calculation breaks. We can no longer just multiply an index by a fixed height to get an offset. Instead, we need to know the cumulative height up to each item. This usually means:

  1. Measuring items after they render: This is problematic because we're trying to render before we know the height. It can lead to scroll jumps.
  2. Estimating heights: Provide an average estimatedItemHeight and update actual heights as items enter the viewport. This means the scrollbar might jump a bit initially but stabilizes quickly.
  3. Pre-calculating heights: If item heights can be determined without rendering (e.g., from data properties like lineCount), you can pre-calculate and store them.

The most common robust approach involves a combination of estimation and measurement. You maintain a map of index -> { height, offset } and update it as items are rendered and measured. When scrollTop changes, you perform a binary search on this map to find the startIndex whose offset is just below scrollTop. It's significantly more complex but necessary for a truly general-purpose virtual scroller.

Libraries like react-window or react-virtualized implement these advanced strategies, and if you need a battle-tested solution, they are excellent choices. Understanding the core mechanism, however, helps you debug and optimize them, or even build a custom solution for niche needs.

Trade-offs and Gotchas

While virtual scrolling is powerful, it's not a silver bullet. Here are a few things to keep in mind:

  • Complex scrollTop management: If you have external controls that change scrollTop (e.g., a
More from the blog
Available for projectsReady to make something fun 🎈

Ready to build the next system?Wanna build something awesome together?

Currently accepting high-impact opportunities in frontend engineering and scalable web applications.Got a cool idea rattling around? Let's grab a virtual coffee and turn it into something people love. ☕