Skip to content

P 04 Virtualized Grid

Karthik P edited this page Oct 21, 2026 · 1 revision

P-04: Virtualized results grid

Context: After P-03, the table rendered once per result, but it rendered every row. Every cell was a DOM node, so showing a 10,000-row result took about 2.5 s for 8 columns and 13 s for 50. Scrolling was slow too, in a way the longest-frame measure didn't show. Dragging the scrollbar through 10,000 narrow rows ran at 15 frames a second. No frame's main-thread work reached 50 ms: the time went to rasterizing the table. 100,000 rows took 24 s to show, and a million couldn't be shown at all.

Hypothesis: Rendering only the rows in view makes the cost of showing and scrolling a result about the same at any row count.

Change: #22, PR #114.

  • Virtualized rows: src/features/results/ResultsTable.tsx uses TanStack Virtual to keep only the rows in view, plus 10 either side, in the page. That's about 30 rows whatever the result's size. Each row reads its cells from the result store as it scrolls in.
  • ARIA grid: the <table> became a role="grid", because a virtualized table positions its rows itself. Rows and cells carry their position in the whole result (aria-rowindex, aria-colindex, aria-rowcount).
  • Keyboard navigation: the grid keeps the focus and points at the active cell with aria-activedescendant. The arrow keys, Page Up and Page Down, Home and End, and Ctrl (⌘) + Home and End move it, following the ARIA grid pattern. Clicking a cell makes it active.
  • Fixed sizes: rows are 24 px. Columns are sized once, from their name and first 100 values, so they don't shift as rows scroll in. The header stays at the top.
  • Results taller than browsers can lay out: Firefox stops at about 17.9 million px, and a million rows at 24 px is 24 million. Above 15 million px (625,000 rows), the scrolling area stays that tall, and each pixel scrolled moves the rows a little further (1.6 px for a million rows).
  • Not included: column resizing and copy moved to #113. Columns aren't virtualized; see Findings.

Method:

  • Before: 32d86cf, which has this experiment's benchmark harness and main's results table. After: fda9e9d. Both had clean working trees and were served by vite preview with the production headers.

  • Same session, back to back, as for P-02 and P-03. An earlier attempt was discarded: background apps slowed the laptop by about 2× partway through, with engine-only queries far slower and erratic.

  • Scenarios: the 8 S and M dataset scenarios (warm), and the new scroll scenarios. Each scroll scenario shows SELECT * … LIMIT <rows>, then scrolls the result from the top for 5 s twice, moving it once per frame:

    • wheel: a steady 3,000 px a second, like spinning a mouse wheel fast;
    • drag: from the first row to the last, like dragging the scrollbar. Every frame shows rows the previous one didn't, which is the hardest case for virtualization.

    The time between frames is taken from requestAnimationFrame, so it includes style, layout, paint and rasterizing. Headless Chromium draws at 75 Hz, so a smooth frame is 13.3 ms.

  • Before ran only the scroll scenarios it could: 10,000 and 100,000 narrow rows and 10,000 wide rows. 100,000 wide rows and a million narrow rows would have taken minutes per iteration, if the tab survived.

  • Iterations: 3 warm-up and 10 measured iterations per scenario; median and p95 reported.

  • Environment: the reference laptop from P-01 (Intel Core i5-8250U, 24 GB, Windows 11 Pro 10.0.26300), on mains power. Chromium 156.0.8078.4 headless.

All times are in milliseconds, given as median / p95.

Showing a result

The 10,000-row result (blinkql:results-render)

From the result reaching the main thread until the frame after its rows are committed.

Dataset Before After Change (median)
S narrow CSV 2,478 / 4,113 16 / 22 −99.4%
S narrow Parquet 2,487 / 2,661 18 / 27 −99.3%
S wide CSV 13,982 / 17,748 63 / 77 −99.6%
S wide Parquet 12,672 / 13,116 96 / 124 −99.2%
M narrow CSV 2,367 / 2,589 16 / 21 −99.3%
M narrow Parquet 2,488 / 3,436 15 / 18 −99.4%
M wide CSV 12,643 / 13,476 75 / 162 −99.4%
M wide Parquet 16,706 / 21,472 67 / 148 −99.6%

The longest frame from pressing Run until the rows are shown fell the same way. Before, it was 2,306–2,422 ms narrow and 12,373–16,384 ms wide. After, no frame reached 50 ms on narrow results, and the longest on wide ones was 63–98 ms.

By row count (scroll scenarios)

Rows Before After Longest frame after
10,000 narrow 2,588 / 3,583 28 / 57 0 / 56
100,000 narrow 24,094 / 38,810 72 / 83 85 / 101
1,000,000 narrow not run 60 / 101 129 / 177
10,000 wide 12,769 / 13,585 98 / 167 93 / 142
100,000 wide not run 143 / 325 173 / 376

Replacing it: the follow-up query

A one-row query run while the 10,000 rows are on screen.

Dataset Before After
Narrow (4 datasets) 214–235 6–11
Wide (4 datasets) 1,060–1,112 7–13

Its longest frame fell from 213–237 ms (narrow) and 1,095–1,113 ms (wide) to under 50 ms on every dataset.

Scrolling

Median time between frames, median / p95 over the 10 runs, and what that median means in frames a second. The p95 column is the median over the runs of each run's own 95th-percentile frame: how bad the worst 1 frame in 20 typically is.

Rows Scroll Before After Before fps After fps 95th-percentile frame, before → after
10,000 narrow wheel 13.4 / 13.4 13.3 / 13.3 75 75 53 → 14
10,000 narrow drag 66.7 / 80.1 13.4 / 34.0 15 75 141 → 40
100,000 narrow wheel 13.5 / 160.4 13.3 / 13.3 74 75 580 → 14
100,000 narrow drag 526.9 / 742.2 13.3 / 26.7 1.9 75 739 → 27
1,000,000 narrow wheel not run 13.3 / 13.3 75 14
1,000,000 narrow drag not run 13.3 / 20.7 75 27
10,000 wide wheel 253.1 / 253.4 13.3 / 28.0 4.0 75 293 → 27
10,000 wide drag 280.1 / 293.5 73.4 / 94.7 3.6 14 337 → 116
100,000 wide wheel not run 13.3 / 88.7 75 27
100,000 wide drag not run 120.0 / 207.4 8.3 229

The scenarios also count frames over 25 ms ("slow frames"). That count isn't comparable here: when every frame takes half a second, a 5-second scroll only has 10 frames to count. So it's left out.

Memory

From performance.measureUserAgentSpecificMemory(), median.

Before After
10,000 narrow rows on screen (4 datasets) 96–104 MB 53–61 MB
10,000 wide rows on screen (4 datasets) 317–325 MB 57–65 MB
100,000 narrow rows on screen 497 MB 66 MB
1,000,000 narrow rows on screen not run 132 MB
100,000 wide rows on screen not run 101 MB
After the follow-up query, narrow 68–75 MB 52–60 MB
After the follow-up query, wide 143–150 MB 52–60 MB

With virtualization, what's left grows with the result's Arrow data, not with the DOM.

For comparison, engine-only queries P-04 doesn't touch moved by −30 to +20% between the two runs. The largest moves were on M wide CSV (scan +20%) and M narrow Parquet (the 10,000-row transfer, −30%). So differences under about 30% between these runs aren't meaningful. The changes above are 4× to 400×.

Findings

  1. Showing a result now costs about the same at any size. A 10,000-row result shows in 15–18 ms (narrow) and 63–96 ms (wide), down from 2.4–2.5 s and 12.6–16.7 s. A million narrow rows show in 60 ms. That confirms the hypothesis: the cost now follows the rows in view, not the rows in the result. The remaining difference between narrow and wide is the number of cells in view (about 30 rows × 8 columns, or × 50).
  2. Narrow results scroll smoothly at any size. Wheel scrolling runs at 75 fps at 10,000, 100,000 and 1,000,000 rows. Dragging the scrollbar also runs at a median of 75 fps. About 1 frame in 20 misses a refresh (27 ms), when a whole screen of new rows has to be read and laid out at once. This meets #22's acceptance criterion: scrolling a 1M-row result stays smooth.
  3. Wide results scroll much better, but not smoothly. Wheel scrolling went from 4 fps to 75 at 10,000 rows. But dragging still runs at 14 fps at 10,000 rows and 8 fps at 100,000. Each dragged frame renders a screenful of new rows with 50 cells each, about 1,500 cells. Unlike narrow results, these frames are long on the main thread too (longest animation frame 151 ms at 10,000 rows, 294 ms at 100,000). Rendering only the columns in view as well should help, since a laptop window shows only some of the 50.
  4. Memory follows the data, not the DOM. With 10,000 wide rows on screen, the page uses 57–65 MB instead of 317–325 MB, and 100,000 narrow rows take 66 MB instead of 497 MB.
  5. The longest frame when showing very large results is the transfer, not the grid. With a million rows, the longest frame from pressing Run is 129 ms, while rendering takes 60 ms. That window includes the Arrow result arriving and being decoded on the main thread, which grows with the result. A trace would confirm it. Streaming record batches (P-05) targets that.

Trade-offs:

  • Rows are one line, 24 px tall. Long values are truncated with "…" and shown in full in a tooltip, as before. Rows can't grow to wrap text.
  • Columns are sized from the first 100 rows. A longer value further down is truncated rather than widening the column. Resizing comes with #113.
  • The browser's Find (Ctrl+F) only finds rows in the page. That's inherent to virtualization. A search in the grid would need to go through the result store.
  • Above 625,000 rows, scrolling is scaled. Each pixel scrolled moves the rows up to about 1.6 px (for a million rows), and the scrollbar thumb is a little less precise. Wheel and keyboard scrolling are unaffected in practice.
  • More code to own. TanStack Virtual's scrollToIndex doesn't know about the scaling, so the grid scrolls rows into view itself, and reads the row count from the virtualizer's options because its scroll listener is set up once. The bundle grows by 28.8 kB, about 5.3 kB gzipped, for TanStack Virtual and the grid.
  • The table became an ARIA grid. Screen readers announce positions in the whole result ("row 500,001 of 1,000,001"), and the grid has a single tab stop. Users of <table> reading commands now use the grid's arrow keys instead.

Limitations:

  • Chromium only, reference laptop only, no throttled profile, as in P-01 to P-03. The scaled scrolling was checked in Firefox and WebKit by the end-to-end tests, not benchmarked there.
  • Headless frame times. Headless Chromium draws at 75 Hz, without a real display. The frame times show where the browser fell behind, but a real screen at 60 Hz has 16.7 ms per frame, a little more room.
  • Synthetic scrolling. The scroll scenarios set scrollTop once a frame. That doesn't go through the browser's own wheel or scrollbar handling, which can scroll on the compositor without the main thread.
  • Not every size ran before. The 100,000-row wide and 1,000,000-row narrow scenarios only ran after P-04. Before P-04 they would have needed every row in the page.
  • Worker memory is still a lower bound (P-01, limitation 1).

Evidence:

Conclusion: Adopt. P-04 makes showing a result 100–400× faster, takes 80–87% less memory with large results on screen, and lets narrow results of any size scroll at the display's frame rate. Wide results still drop frames while dragging. Virtualizing columns is the next step for them, before the canvas grid (P-11).

Clone this wiki locally