When Virtualization Lies

A Svelte table looked like a framework failure until browser profiling showed the virtualizer was rendering every row because CSS had removed the scroll viewport.

A Svelte table looked like a framework failure until browser profiling showed the virtualizer was rendering every row because CSS had removed the scroll viewport.

I almost blamed the framework. A small property browser had 84,131 parcel rows, chunked JSON, and a virtualized table. The pure data path looked fine in Node. The actual browser was unusable: slow initial load, slow search, slow sort, and multi-second long tasks.

The tempting conclusion was that Svelte was the wrong tool for a dense data browser. The profiler said something narrower and more useful: virtualization was not actually happening.

The Control

I added a browser profiling harness that opens Chrome through the DevTools Protocol, waits for the app status text, runs a search, clears it, changes the sort, and prints DOM geometry, browser metrics, long tasks, and an optional CPU profile.

scripts/profile_browser.mjs
1 PROFILE_READY_TIMEOUT_MS=5000 CHROME_REMOTE_PORT=9224 \
2 node scripts/profile_browser.mjs

Then I built a plain DOM control page that loaded the same /parcel-data/*.json files and rendered a comparable virtual table. Same Vite server. Same 84,131 rows. Same search and value sort. No framework.

Run Ready DOM rows Search Clear Sort Heap
Svelte, broken 14k / 84k after 5.4s 14,000 2,209 ms 5,333 ms 4,812 ms 286 MB
Vanilla control 408 ms 40 183 ms 183 ms 224 ms 26 MB
Svelte, fixed 269 ms 21 187 ms 183 ms 232 ms 56 MB

That table changed the question. This was not "can Svelte handle 84k rows?" The vanilla control proved the data and browser could handle the workload. The broken Svelte page was doing a different workload.

The Smoking Gun

The DOM geometry made the failure obvious. In the broken app, after only five seconds, Chrome reported this:

  • tableRows: 14000
  • bodyHeight: 815192
  • scroller.clientHeight: 812000
  • scroller.scrollHeight: 812000

The table scroller was not a viewport. It was growing to the height of the virtual spacer. TanStack Virtual did what it was asked to do: it looked at an 812,000 px tall visible area and concluded that 14,000 rows were visible. The framework then rendered them.

The CPU profile made the same point from another angle. The hottest function was not the filter loop. It was formatMoney, which called Number(...).toLocaleString(...) for every rendered row. Two samples of that function alone accounted for about 16 seconds of self time in one bad run. Svelte dev-mode stack helpers showed up too, but they were downstream of the real mistake: thousands of rows were being rendered.

The Fix

The important fix was CSS, not a new framework. The table needed a real height so the scroller could be a scroller.

frontend/src/app.css
1 .workbench {
2 display: grid;
3 grid-template-columns: minmax(760px, 1fr) 380px;
4 gap: 12px;
5 min-height: 680px;
6 align-items: start;
7 }
8
9 .table-card {
10 min-width: 0;
11 height: clamp(520px, calc(100vh - 230px), 760px);
12 display: grid;
13 grid-template-rows: 42px minmax(0, 1fr);
14 }
15
16 .table-scroller {
17 overflow: auto;
18 position: relative;
19 min-height: 0;
20 }

I also stopped constructing a new currency formatter on every cell render. That was not the root cause, but the CPU profile had made it visible as a cheap secondary cleanup.

frontend/src/App.svelte
1 const currencyFormatter = new Intl.NumberFormat("en-US", {
2 style: "currency",
3 currency: "USD",
4 maximumFractionDigits: 0,
5 });
6
7 function formatMoney(value) {
8 return currencyFormatter.format(Number(value || 0));
9 }

After that, the same Svelte page loaded all 84,131 parcels in 269 ms in the harness, rendered 21 DOM rows, and kept search, clear, and value sort near the artificial debounce floor. The final CPU profile no longer had currency formatting as a hotspot; the largest named app functions were normal one-time work like decoding parcels and sanitizing local notes.

The Next Bottleneck

Once the table stopped lying, the data started looking sparse. The public parcel layer had addresses and values, but not bulk residential owner names.

King County eReal exposed owner names per parcel, but it rate-limited fast crawls. The fix was not "scrape harder." It was cache every result, crawl slowly, and stop on access-limit pages.

scripts/enrich_assessor_names.py
1 python3 scripts/enrich_assessor_names.py \
2 --limit 1000 \
3 --distance-miles 1 \
4 --delay 1.25 \
5 --workers 1 \
6 --max-access-limited 3

That moved the closest 2,000 parcels from 967 owner names to 1,955. The whole North Seattle slice moved from 14,778 rows with any public name to 15,670.

The last polish step was display normalization. The source stays raw, but the UI gets a cached display string for sorting, table cells, map popups, and the detail panel.

frontend/src/App.svelte
1 const rawPublicName = publicNameFromFields(row[1], row[3], row[2]);
2 const displayName = formatPublicName(rawPublicName);
3
4 return {
5 public_name: rawPublicName,
6 display_name: displayName,
7 };
Raw public record Rendered name
HOFF MICHAEL Michael Hoff
CROSBY MATTHEW+SHARON CHIN Matthew Crosby & Sharon Chin
XU ZHIGUO/LI YIYUAN Zhiguo Xu & Yiyuan Li

The latest profile still kept the Svelte path in bounds: 347 ms until ready, 21 table rows in the DOM, and search/sort under a quarter second.

The Lesson

Virtualization is a contract between data, rendering, and layout. The data structure can be perfect. The framework can be fine. The virtualizer can be working exactly as designed. If the scroll container is not constrained, the contract is broken and the app quietly becomes an unvirtualized table.

This is why the vanilla control mattered. Without it, "Svelte is slow" would have been a plausible story. With it, the story collapsed into a specific measurement: the framework path had 14,000 DOM rows while the control had 40.

The decision was not to eject from Svelte. The decision was to make the browser tell us what it was actually rendering.