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.
| 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.
| 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.
| 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.
| 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.
| 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.