A row window makes scrolling free and does nothing for the load

# react# performance# javascript# webdev
A row window makes scrolling free and does nothing for the loadIsfaaq M. F. Emambocus

Everyone keeps telling me to virtualize a React table past a few hundred rows. I built a row window...

Everyone keeps telling me to virtualize a React table past a few hundred rows. I built a row window for mine and then measured the same table twice: Once as it ships, and once with the window turned off by raising its threshold above the row count.

It buys scrolling. It does nothing at all for the load, and that is the half nobody publishes.

What I measured

Compact density, ten columns, a 120Hz display, and a batch of rows loaded by a button that stands in for a fetch with a 450ms pause.

Every number below comes from the production build, served locally. Never the dev server: the same 5000 row batch blocks the main thread for 1511ms through Vite's dev server and 1078.2ms from the bundle a user actually gets, hence a dev reading overstates it by about 40%.

Scrolling

At 1000 rows with every row in the DOM, the body has 12,051 cells, and a frame during a scroll sweep takes 16.7ms. A free frame on this display is 8.3ms, so the table misses one out of two and stays at 60 fps. It's fine. No one would file a bug about it.

At 5000 rows it stops being fine. 60,051 cells, 78.1ms per frame, the ninetieth percentile at 81ms, the worst frame at 122.1ms. Thirteen frames a second is the difference between a table that lags and a table that has stopped answering.

With the window on, both sizes put 401 cells in the body and scroll at 8.3ms. That is the display's own floor, so there is nothing left in the measurement to look at.

The load, which the window does not touch

Virtualization is usually sold as the thing that makes a big list fast, so here is the other number: The longest single frame the main thread was blocked for after the click.

At 1000 rows it is 183.4ms without the window and 216ms with it. At 5000 it is 1083.4ms without and 1078.2ms with. The windowed table renders 48 rows instead of 5017, and the load costs the same, or a little more.

It is not the rows, and it isn't the data being built either. The click handler that makes the batch and sets the state returns in 0.6ms, over four presses between 0.5ms and 0.7ms. I haven't pinned down where the rest of that second goes, and I would rather say that than guess. What I can say is that a row window is a scrolling optimization. If your complaint is that the table takes a second to appear, this is not the fix for it.

A frame number measures your display too

There are two numbers on my own docs that I got wrong, and this is where it showed. I had published 22.5ms against 19.6ms at 1000 rows, and 84.4ms against 18.4ms at 5000. Today the same sweep gives 16.7 against 8.3, and 78.1 against 8.3.

The 5000 row pair held on the unwindowed side, 84.4ms to 78.1ms, because at 78ms a frame the display is nowhere near the limit. Neither windowed reading held, and the reason is the screen rather than the code. This machine now drives a 120Hz panel, where a frame with nothing to do costs 8.3ms instead of 16.7ms, and both of the old windowed readings were sitting on the 60Hz floor and measuring the display. The 1000 row pair moved on both sides as well, and 16.7ms is exactly two frames on this panel, which means that reading is not far off the floor either.

A frame interval is only a measurement of your code while your code is the slow part. Publish the refresh rate next to it, or the number is not something anybody else can check.

How the window is built

Past 100 rows the body renders what the viewport holds plus six, between two spacer rows that carry the height of everything outside the window. They are spacer rows rather than absolute positioning, because a tr taken out of flow loses table-fixed, the sticky head and both pinned columns, and I wanted all three.

The row height is read off the head row, not a body row. A body row is re-keyed on every scroll, hence a ResizeObserver on one ends up watching a node that has already been detached, and the scroll height stops following the density: at comfortable it reported 160,272px where it owed 200,040px. The head row carries the same height and keeps its identity for the life of the table.

The window is arithmetic rather than measurement, and it can be, because every row is exactly the same height. That height is one token on a density scale, where a single attribute on the root element moves row height, control height, padding and label size together, and every component reads it. The window is a side effect of having built that first.

The density knob moving through comfortable, compact and dense

The table belongs to a component registry I've been building for data-dense interfaces. This first appeared at sley-ui.dev/notes/row-window. Happy to answer anything about either.