Virtual scrolling a million rows — where it actually breaks
· 5 min read
The textbook version of virtual scrolling is four lines of arithmetic. Give a
spacer element the height of every row added together, watch scrollTop, and
render only the slice that falls inside the viewport. A million rows become
about two dozen DOM nodes — ours renders 24 at a typical viewport height.
That part works on the first try. Then you scroll to the very bottom of a million rows and the last six are missing.
The browser is lying about your spacer
Set an element's height to 40,000,000 px and read it back. You will not get 40,000,000. Browsers cap how tall an element may be, silently, and the cap is neither documented nor the same everywhere. From our own measurements:
| Engine | Ceiling |
|---|---|
| Chromium | 33,554,428 device pixels (26,843,542 CSS px at 125% display scaling) |
| Firefox | 17,895,697 |
A million rows at 40 px is 40,000,000 px of content. Chromium quietly gives you 26.8 million of it. Now the scroll engine and the DOM disagree about where row 999,999 lives, and the disagreement is exactly the amount that got truncated. The rows are not "not rendered" — they are mapped to a scroll position that cannot be reached, because the scrollbar physically ends first.
Worse, the cap moves. It is in device pixels, so display scaling changes it: the same machine at 100% and at 125% has different limits. A user with a 4K monitor and a scaled desktop hits a different wall than you do.
Do not measure the ceiling — pick one below it
The obvious fix is to probe: create an element, grow it until the browser stops agreeing, and use that number. We tried this. It is worse than a constant, for two reasons.
First, the probe is not stable. Chromium was measured keeping 26,843,040 of a requested 26,843,041 — off by one, and not always the same one. Under compression the returned value drifts again. So you build a scroll model on a number that is almost right, which is the worst kind of number: everything works until someone scrolls to the last screen.
Second, probing costs a layout on every mount, on every machine, forever, to rediscover a constant.
We took the boring option instead:
export const DEFAULT_MAX_CANVAS_HEIGHT = 16_000_000;
Sixteen million CSS pixels is comfortably under every engine's limit, including Firefox's and including scaled displays. Above that height the engine stops mapping rows to pixels one-to-one and starts compressing: the canvas stays a fixed size and scroll offsets translate into row indices through a ratio.
You lose nothing a user can perceive. At 400,000 rows you are already scrolling past a hundred rows per pixel of thumb movement; nobody is arriving at row 712,449 by dragging. What you gain is a scroll model that is exactly as tall as it claims to be.
The second failure: what counts as "the end"
Once the canvas is compressed, "am I at the bottom?" stops being
scrollTop + clientHeight === scrollHeight. Floating-point ratios do not land
on exact equality, and browsers round scrollTop differently at the extremes.
So the end needs a tolerance — a band of pixels near the physical end that counts as the end. Without it the last page is reachable in principle and unreachable in practice, which is precisely the bug users report as "the last few rows are missing".
A related trap: if the grid has a footer or totals row overlaying the bottom of the viewport, a row scrolled flush with the bottom edge sits behind it. The row is rendered, positioned correctly, and invisible. That one is not a scroll bug at all, and it will waste your afternoon if you assume it is.
Rendering few rows is not the same as being fast
Virtual scrolling bounds how much DOM you build. It does nothing about the work you do before rendering — and at a million rows that work is where the frames go.
The one that surprised us: encoding columns for the worker. A straight loop over a million values blocked the main thread for 650 ms. Nothing was rendering badly; the grid simply stopped responding while it prepared data. Slicing that loop into chunks that yield between batches brought the longest block down to 58 ms — the same total work, spread so the browser can interleave input.
The lesson generalises: once you are virtualised, your remaining latency is almost never in rendering. It is in a synchronous pass over the full dataset that someone wrote before the dataset was a million rows long.
What to test
If you are building or evaluating a grid, three checks find most of this:
- Scroll to the absolute bottom of your largest dataset and count the last rows. This is the check that catches the height ceiling.
- Do it at 125% display scaling. The ceiling is in device pixels; a bug that hides at 100% appears here.
- Type in a filter while the full dataset is loaded and watch for dropped frames. This is where the synchronous full-scan hides.
Our own grid failed the first check three separate times before the fix above stuck — twice because we trusted a measured ceiling, once because the spacer overflowed the render block. Each time it looked fine from the top of the list.
Try it yourself: the Big Data Performance playground runs from 1K to 2M rows in the browser.