Marcio Cunha

How to render long lists in React maintaining 60 FPS

Learn how DOM virtualization solves freezing issues when displaying thousands of items in the browser, keeping interfaces running smoothly at 60 FPS.

Marcio Cunha•3 min
Also available in:PortuguêsEspañol
Summary
  • DOM virtualization renders only the elements visible within the browser viewport
  • Precise offset calculations ensure the native scrollbar functions without jumps or stutters
  • Dynamically managing event listeners prevents memory leaks in extensive tables and feeds
  • Maintaining a 60 frames per second render rhythm requires intelligent DOM node recycling
  • Optimizing heavy data structures complements the performance gain achieved through pure virtualization

Why browsers freeze when displaying thousands of items

When a web application attempts to load ten thousand records all at once on the screen, the browser engine suffers a severe performance hit. The Document Object Model, which is the tree of elements the browser builds in memory to render the page, grows disproportionately and consumes gigabytes of resources. In practice, this means that with every click or scroll movement, the system must recalculate the positioning of every single pixel, generating those annoying freezes that ruin the user experience. To explore other optimization aspects in modern interfaces, you can check our article on how to create fluid animations in React with Framer Motion, where we discuss the importance of the rendering pipeline.

The concept of virtualization and the sliding window trick

List virtualization solves this problem by limiting processor effort exclusively to what the human eye can actually see at that exact millisecond. Instead of injecting ten thousand rows into the HTML code, the application creates a container with the exact height the entire list would have, but internally draws only twenty or thirty elements that fit on screen. As the user scrolls down, the component recalculates which data should appear and recycles existing visual nodes. This dynamically updated sliding window tricks the user's brain into perceiving an infinite, heavy list, while the hardware works with a minimal and constant workload.

Calculating positions and maintaining the real scrollbar

The biggest technical challenge when implementing this strategy manually is making the side scrollbar behave exactly as if all elements were present. If the main container's height reflects only the twenty visible items, the scrollbar will shoot straight to the bottom with a tiny touch. To prevent this, we multiply the total number of items by the estimated height of each row, applying this value to an invisible spacer element. When the scroll event fires, we capture the exact top position, divide it by the row height to find the starting index, and apply a CSS transform to push the visible elements into the correct place.

Implementing a basic virtualization component

To get our hands dirty, let us structure a simple component in React that calculates visible items based on viewport height. The code below demonstrates how to intercept the scroll event and update state performantly, ensuring the browser does not waste precious cycles recalculating layout unnecessarily.

import React, { useState } from 'react';

export function VirtualList({ items, itemHeight, containerHeight }) {
  const [scrollTop, setScrollTop] = useState(0);
  const totalHeight = items.length * itemHeight;
  const startIndex = Math.floor(scrollTop / itemHeight);
  const visibleCount = Math.ceil(containerHeight / itemHeight);
  const endIndex = Math.min(startIndex + visibleCount + 1, items.length);
  const visibleItems = items.slice(startIndex, endIndex);

  return (
    <div
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
      style={{ height: containerHeight, overflowY: 'auto', position: 'relative' }}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div style={{ transform: `translateY(${startIndex * itemHeight}px)` }}>
          {visibleItems.map((item, index) => (
            <div key={startIndex + index} style={{ height: itemHeight }}>
              {item}
            </div>)
          )}
        </div>
      </div>
    </div>
  );
}

Common pitfalls and the challenge of dynamic heights

The previous code works perfectly when all items share the exact same fixed height, but the scenario changes radically when displaying long texts or content that resizes on its own. Dynamic heights require post-render measurements, where the component adjusts the scroll position dynamically based on the real size returned by the browser. Ignoring this step results in jumps in the scrollbar and a truncated visual experience that frustrates any user trying to quickly browse through the data table. To complement this implementation in production environments, see our related technical analyses on How to train custom language models with LoRA datasets and How to implement asynchronous CQRS in high traffic APIs.

Final thoughts on interface performance

Ensuring a consistent rate of sixty frames per second in complex web applications demands architectural discipline and the correct use of native browser tools. List virtualization ceases to be a mere technical nicety and becomes a mandatory requirement in modern dashboards, corporate chats, and high-density information feeds. By adopting these practices, you protect the user device against memory consumption spikes and secure a fluid experience, regardless of the volume of data processed.