The problem with line by line on a virtualized scrolling web app
A 13-million-line JSON file, a scrollbar that physically cannot point at a single line, and the day j stopped jumping 90 lines at a time.
The bug
Fast JSON Viewer has Vim-style keys: j moves down one line, k moves up one line. On normal files this works the way you would expect. Open a 95 MB file with around 13 million lines, press j once, and the view lurched down by roughly 90 lines. Press k and it jumped back up by a similar amount. One keypress, one line: that was the whole contract, and it was broken on exactly the files where careful navigation matters most.
To understand why, you need two pieces of background: how the viewer shows a 13-million-line file at all, and what it does when even that trick runs out of room.
Background: virtual scrolling
The viewer is tested with files up to 20 GB. You obviously cannot put 13 million <div> rows in the DOM; the browser would fall over long before that. So the viewer uses virtual scrolling: it only ever renders the handful of rows that fit on screen, plus a small buffer, and recycles them as you scroll.
The structure is simple. There is one tall, empty spacer element whose height equals totalLines × LINE_H. That spacer exists only to give the scrollbar something to scroll over, so the bar's size and position feel right. On top of it sits a small canvas holding the visible rows, and the code moves that canvas to match the scroll position (scrollTop and the scrolling methods are defined in the W3C CSSOM View Module):
// Each display line is LINE_H pixels tall. The spacer is the full
// document height; the canvas holds only the on-screen rows.
const firstVis = toLine(scrollTop); // which line is at the top
const canvasTop = toPixel(firstVis); // move the canvas there
renderRows(firstVis, firstVis + visCount); // paint just those rows
The two helpers convert between lines and pixels, and in the normal case they are trivial:
let toPixel = l => l * LINE_H; // line number -> scrollbar pixels
let toLine = px => Math.floor(px / LINE_H); // pixels -> line number
As long as the spacer can be as tall as the document, toLine and toPixel are exact inverses and everything lines up. The trouble starts when the spacer cannot be that tall.
Background: scale mode
Browsers cap how tall an element can be before scrolling breaks down. The safe ceiling is around 10 million pixels; past that the scrollbar becomes unreliable across engines. A 13-million-line file at 20 pixels a line wants a spacer about 268 million pixels tall. That is 26 times over the limit.
This ceiling is worth being honest about: it is not defined by any web standard. The CSSOM View Module specifies how scrollTop and friends behave but says nothing about a maximum element height, so the cap is an implementation detail that each engine picks for itself and that differs between them. The 10 million figure is a conservative value that holds across the browsers we test.
So the viewer has a scale mode. When the true document height would blow past the ceiling, it caps the spacer at the limit and compresses the mapping to fit:
const MAX_SPACER = 10_000_000; // browser-safe ceiling, in pixels
scale = geomTotal * LINE_H > MAX_SPACER;
spacerH = scale ? MAX_SPACER : geomTotal * LINE_H;
// In scale mode, lines and pixels no longer map 1:1.
toPixel = l => scale ? (l / geomTotal) * MAX_SPACER : l * LINE_H;
toLine = px => scale ? Math.floor((px / MAX_SPACER) * geomTotal) : Math.floor(px / LINE_H);
Think of it as squishing the whole scrollbar down to fit. Instead of each line owning 20 pixels of the bar, each line now owns a sliver. For this file the math worked out to about 0.75 pixels per line.
And there is the catch. A scrollbar can only stop on whole pixels. It cannot move by less than 1 pixel, the same way you cannot take a step shorter than your own foot. If one line is 0.75 pixels wide, the smallest move the scrollbar can make, a single pixel, already covers more than one line. Bump it by the smallest amount the browser allows and you have skipped past the line you wanted. There simply is not enough room between lines to land on just one.
toLine(scrollTop) stops being a faithful answer to "what is on screen." The pixel scrollTop is too coarse to name a single line, so any line number you reconstruct from it is a rounded guess.The fix that did not work
The first instinct was to keep doing pixel math, just more carefully. If one keypress should move one line, then move scrollTop by toPixel(line + 1) - toPixel(line) and let the render follow. On a normal file that is exactly LINE_H and it works. In scale mode that delta is 0.75 pixels, the browser rounds it to a whole pixel, and the rounding is larger than the thing you were trying to move. You cannot measure single lines with a ruler whose smallest mark is 90 lines wide. No amount of nudging the pixel value fixes a unit that is coarser than your target.
How I actually found it
Reasoning in the abstract kept fooling me, because on paper the pixel math looks fine. So I stopped guessing and added a spy. One console.log on every j press, printing what the code believed next to what was actually on screen:
// One flat line per keypress: code's view vs. the DOM's view.
console.log(
`[jk] n=${n} scale=${scale} pxPerLine=${(MAX_SPACER / geomTotal).toFixed(4)}` +
` | scrollTop ${before.toFixed(2)}->${scrollEl.scrollTop.toFixed(2)}` +
` | toLine ${toLine(before)}->${toLine(scrollEl.scrollTop)}` +
` | DOM top line ${domBefore}->${domTopLine()}`
);
I kept it on one flat line on purpose, so the console would not collapse it into an expandable object and hide the part I needed. Reload, press j once, read the line:
[jk] n=1 scale=true pxPerLine=0.7464 step=71.90 | scrollTop 0.00->72.00
| toLine 0->96 | DOM top line 1->62 | canvasTop 0.00->44.78 rows=97
That single line was the whole answer. The code thought the top line was now 96 (that is toLine reading the new scrollTop). The DOM was actually showing line 62. A fixed skew of around 36 lines, and a per-press jump of 61 lines that tracked the 72-pixel scrollTop move, not any line count. The pixel position and the painted content had quietly come apart, and the log put them side by side so I could not argue with it. The lesson: when the math feels obviously right but the behavior is wrong, print the math next to the ground truth and let the two disagree out loud.
The fix: anchor on a line, not a pixel
The real problem was the direction of trust. The code derived the line from the pixel. In scale mode the pixel is too coarse to name a line, so that direction can never be exact. So I flipped it. Keyboard navigation now keeps a sticky note that says which line belongs at the top, and the paint reads from that note instead of from scrollTop:
// The line pinned at the viewport top during j/k. null means
// "follow scrollTop" (the normal path for wheel / drag / scrollbar).
let keyAnchor = null;
When you press j, the code reads the line currently painted at the top straight from the DOM (ground truth, even when the scrollbar is lying), adds one, and pins that as the anchor. scrollTop is still set, but only to keep the scrollbar roughly in place; it is no longer the source of truth.
function scrollByLines(n) {
const cur = keyAnchor != null ? keyAnchor : domTopLine();
const target = clamp(cur + n, 0, maxTop);
keyAnchor = target; // the sticky note: "show this line at top"
programmaticScroll = true; // our own scroll write; don't drop the anchor
scrollEl.scrollTop = toPixel(target); // position the bar, roughly
forceRepaint(); // paint FROM the anchor, not from scrollTop
}
The paint then honors the anchor when one is set and falls back to the old pixel path when it is not:
const firstVis = keyAnchor != null ? keyAnchor : toLine(scrollTop);
const canvasTop = keyAnchor != null
? scrollTop - (firstVis - from) * LINE_H // land the anchored line at the top
: toPixel(from);
A programmaticScroll flag lets the anchor survive the scroll event that our own scrollTop write fires. Any real wheel, drag, or scrollbar scroll drops the anchor and reverts to the normal pixel path, and the jump commands (gg, G, search, collapse) clear it explicitly because they renumber lines wholesale.
The detail I like most: in a normal file, scrollTop - (firstVis - from) * LINE_H reduces to exactly toPixel(from). The anchor path and the old pixel path produce identical geometry. So nothing changes for ordinary files; the anchor only takes over when the document is compressed and the pixel can no longer be trusted. space, d, u, PageDown, and PageUp now route through the same line stepper too, so they move whole lines in scale mode instead of raw pixels.
Proving it, not believing it
Plain reasoning had already fooled me once, so I did not trust the fix on reasoning either. I wrote a test that loads a 5-million-line file (well into scale mode), presses j ten times, and asserts from the DOM that the top line advanced by exactly ten, then presses k and checks it reverses:
const start = await topLineInDom(page);
for (let i = 0; i < 10; i++) await page.keyboard.press('j');
expect(await topLineInDom(page) - start).toBe(10); // exactly ten lines, not ~900
It passes. The whole episode is a small reminder that on large data the obvious unit of measurement (the pixel) can quietly stop being able to represent the thing you care about (the line), and the only way to catch it is to print the two side by side and let the numbers contradict each other.