Windowing a 10k Row List Without react-window

We had a 10k row list freezing scroll. react-window worked but was overkill for our shape. Here is the 60 line virtualizer we shipped instead.

JavaScript
Frontend
3 snippets
react
performance-optimization
references
norapetrov

By @norapetrov

February 10, 2026

·

Updated May 20, 2026

765 views

15

4.3 (10)

// Step 1: a pure function with no React, no DOM, no refs. Given the current
// scrollTop, item height, container height, total count, and an overscan
// buffer, return which slice of the array to render and where to position it.
// Easy to unit-test, easy to reason about. The hook in the next accordion is
// only 15 lines once this is extracted.

function getVisibleRange(scrollTop, itemHeight, containerHeight, totalCount, overscan) {
    const safeScroll = Math.max(0, scrollTop);
    const safeItem = Math.max(1, itemHeight);
    const safeOver = Math.max(0, overscan | 0);
    const firstVisible = Math.floor(safeScroll / safeItem);
    const visibleCount = Math.ceil(containerHeight / safeItem);
    const startIndex = Math.max(0, firstVisible - safeOver);
    const endIndex = Math.min(totalCount - 1, firstVisible + visibleCount + safeOver);
    return { startIndex, endIndex, offsetY: startIndex * safeItem };
}

// Concrete walkthrough. Container is 600px tall, each row is 40px, list has
// 10,000 rows. We ask for an overscan of 5 rows above and below the viewport.
const itemHeight = 40;
const containerHeight = 600;
const totalCount = 10000;
const overscan = 5;

// At the top of the list.
console.log('scroll=0    ->', getVisibleRange(0, itemHeight, containerHeight, totalCount, overscan));
// Halfway through.
console.log('scroll=4000 ->', getVisibleRange(4000, itemHeight, containerHeight, totalCount, overscan));
// Near the bottom: endIndex clamps to totalCount-1.
console.log('scroll=399000 ->', getVisibleRange(399000, itemHeight, containerHeight, totalCount, overscan));
// Negative scroll (rubber-band on iOS) clamps to 0.
console.log('scroll=-200 ->', getVisibleRange(-200, itemHeight, containerHeight, totalCount, overscan));
// Sanity: rendering 25 rows out of 10,000.
const sample = getVisibleRange(4000, itemHeight, containerHeight, totalCount, overscan);
console.log('rendered window size:', sample.endIndex - sample.startIndex + 1, 'of', totalCount);

I keep the math out of the hook because every windowing bug I have ever shipped lived in this function, and a pure function is the only place I can test those bugs without spinning up a renderer. Three clamps matter. First, Math.max(0, scrollTop) because iOS rubber-band scrolling reports negative values on overscroll. Second, Math.max(1, itemHeight) because a zero-height row would divide by zero on the first pass. Third, Math.min(totalCount - 1, ...) so the end index never points past the array. The overscan buffer is what hides the white-flash when you scroll fast: render a few rows above and below the viewport so the next ones are already in the DOM.