Skip to content

Commit d695098

Browse files
blog: TanStack Virtual just got a lot faster, and finally handles iOS (#934)
* blog: TanStack Virtual perf + iOS release post Release writeup for the recent virtual-core perf rewrite + iOS Safari momentum-scroll handling. Covers the Map-clone bug fix, the lazy typed-array fast path, the new iOS deferral, the backward-scroll default change, and the takeSnapshot() API. Smoke tests for query/router/table docs fail in worktree (need DB env vars); the blog smoke test passes for this post. * ci: apply automated fixes * blog: hyphenate end-to-end * blog: drop unrelated RSC reference from benchmarks paragraph --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 7fd20ef commit d695098

1 file changed

Lines changed: 86 additions & 0 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
---
2+
title: TanStack Virtual just got a lot faster, and finally handles iOS
3+
published: 2026-05-19
4+
authors:
5+
- Tanner Linsley
6+
library: virtual
7+
excerpt: A perf-focused release for TanStack Virtual. Cold mount at 100k items is 5x faster, a hilarious worst-case bug now runs 1382x faster, iOS Safari momentum scroll works for the first time, and scroll-up jank with dynamic items is gone by default.
8+
---
9+
10+
I spent three days last week auditing TanStack Virtual end-to-end, and what came out of it is the biggest single perf release the library has shipped in years. Cold mount on a 100k-item list dropped from 6.1 ms to 4.5 ms in real React. A worst-case `resizeItem` storm on 10k items went from nearly two seconds to 1.3 milliseconds. iOS Safari momentum scroll, which had been broken for years on dynamic-height lists, now actually works. Scroll-up jank with dynamic items, the single largest complaint cluster in our tracker, is gone by default.
11+
12+
The work was a mix of bug fixes, a substantial internal rewrite for the hot path, and a new iOS-specific code path. Most of it landed in `virtual-core` so every framework adapter benefits. Here's what changed and why.
13+
14+
## One bug was genuinely embarrassing
15+
16+
Before measuring anything I read the entire `virtual-core` source looking for things that were quantifiably bad, and the worst one was a Map clone hiding in plain sight. Every time `resizeItem` ran, we'd do `this.itemSizeCache = new Map(this.itemSizeCache.set(item.key, size))`, which copies the whole size cache into a fresh Map just to invalidate a memo dep. For a 10k-item list where every item resizes once on mount, that's about 50 million wasted operations and a 1.9-second cold mount that nobody had pinned down. The fix was four lines (use a version counter, same dep pattern, integer comparison) and dropped that to 1.3 milliseconds. **1382× faster.**
17+
18+
Below it were the usual smaller suspects: an `Object.entries+delete` pattern in `setOptions` that was triggering V8's dictionary-mode deopt on every render, a `Math.min(...arr)` spread that could blow the argument-list limit at 125k items, an `elementsCache` leak when React replaced a measured node, a `useReducer(() => ({}), {})` rerender pattern allocating per scroll event. None catastrophic alone, but together they explain why our issue tracker had recurring complaints about scroll stutter and slow initial renders on large lists.
19+
20+
## The real ceiling was object allocation at scale
21+
22+
After the audit fixes we still mounted a 100k-item list slower than we should have, and the cause was that we were allocating a `VirtualItem` object per index even though only ~50 are ever visible. The fix is the biggest single change in the release.
23+
24+
For single-lane lists (the default and the common case) we now store start and size as a flat `Float64Array` and only construct `VirtualItem` objects when something actually reads `measurements[i]`. The public API still hands out an `Array<VirtualItem>` shape, but it's a `Proxy` that materializes lazily and caches. Internal hot paths read straight from the typed array, skipping the Proxy.
25+
26+
Cold mount at 100k went from 6.1 ms to 4.5 ms in real React, and 2.5 ms to 0.54 ms in the synthetic bench. At 500k items it's now 2.7 ms instead of 14. The work is fully backward compatible: `measurementsCache` still satisfies its `Array<VirtualItem>` contract, internal consumers continue to read `[i].start` and `[i].end` the same way they used to, and only the lanes>1 path keeps the old eager allocation because lane assignment is order-dependent and harder to defer cleanly.
27+
28+
## iOS Safari is rude
29+
30+
If you've ever called `el.scrollTop = x` during a momentum scroll on iOS Safari, you know what happens: momentum dies, page snaps, user sees a jolt. iOS WebKit treats any programmatic scrollTop write during a touch-driven scroll as a cancel instruction, which is the opposite of what virtualization libraries want to do, because virtualization libraries write scrollTop in response to size measurements arriving.
31+
32+
We had no iOS-specific handling at all. The "scroll stops abruptly when content above me resizes" complaints in our tracker have been some flavor of this for years.
33+
34+
The fix defers the scrollTop write while a finger's on the screen, during the 150 ms post-touchend momentum window, and during the elastic-overscroll bounce. The accumulated adjustment flushes in a single write once everything actually settles, and the user keeps their momentum. About 370 bytes of iOS-specific code that doesn't tree-shake away on non-iOS bundles since the detection is runtime, but the per-event cost on non-iOS is one cached boolean check. That's an acceptable trade given how much of mobile traffic is iOS.
35+
36+
## The backward-scroll jank had been festering for five years
37+
38+
The biggest single complaint cluster in our issue tracker is "items jump while I scroll up" with dynamic heights, and the cause is that we were writing scrollTop on every above-viewport resize to keep the visible window stable. That makes sense during forward scroll, but during backward scroll the same write actively pushes the user past where they're trying to go. The community had independently rediscovered the same workaround five separate times across the years.
39+
40+
We just gate it on direction now. Forward scroll and mount-time adjustments still fire, backward scroll skips them. Anyone who wants the old behavior can supply `shouldAdjustScrollPositionOnItemSizeChange` (it was already there) and ignore the direction.
41+
42+
## A new method for scroll restoration
43+
44+
`virtualizer.takeSnapshot()` returns the currently-measured items as plain `VirtualItem` objects, suitable for persisting through state storage and feeding back as `initialMeasurementsCache` on remount. Pair with the current `scrollOffset` and you get exact scroll restoration after route navigation:
45+
46+
```tsx
47+
// On unmount
48+
const snapshot = virtualizer.takeSnapshot()
49+
const offset = virtualizer.scrollOffset
50+
sessionStorage.setItem('myList', JSON.stringify({ snapshot, offset }))
51+
52+
// On remount
53+
const saved = JSON.parse(sessionStorage.getItem('myList') ?? 'null')
54+
useVirtualizer({
55+
count: items.length,
56+
estimateSize: () => 50,
57+
getScrollElement: () => parentRef.current,
58+
initialMeasurementsCache: saved?.snapshot,
59+
initialOffset: saved?.offset,
60+
})
61+
```
62+
63+
Only items the consumer actually rendered show up in the snapshot, since unmeasured items can fall back to `estimateSize` on restore.
64+
65+
## The numbers
66+
67+
Compared to the current published version:
68+
69+
| Metric | Before | After |
70+
| ----------------------------------------------------- | ----------- | --------------- |
71+
| Cold mount @ 100k items (real React) | 6.1 ms | 4.5 ms |
72+
| Cold mount @ 100k items (synthetic) | 2.5 ms | 0.54 ms |
73+
| Cold mount @ 500k items (synthetic) | 14 ms | 2.7 ms |
74+
| `resizeItem` storm on 10k items | 1.9 s | 1.3 ms |
75+
| `setOptions` × 10,000 (per render) | 14.4 ms | 1.3 ms |
76+
| `scrollToIndex` landing accuracy on dynamic 10k lists | within 1 px | 0.0 px |
77+
| iOS Safari momentum scroll | broken | works |
78+
| Backward-scroll jank with dynamic items | recurring | gone by default |
79+
80+
Bundle delta is about +900 bytes gzip, mostly the lazy fast-path machinery and the iOS code. Production minified comes out around 6.1 kB total. 91 unit tests, all green.
81+
82+
## What's still on the list
83+
84+
Reverse infinite scroll for chat use cases is the one big thing missing, and given how much of the modern web is now a streaming UI on top of a list, it deserves its own release with its own design pass rather than getting wedged into this one. A Fenwick-tree memory rewrite for 1M+ item lists is the other piece; it'll come if a real-world case actually asks for it.
85+
86+
I also built a cross-library benchmark suite at `benchmarks/` while I was at it, since I wanted to verify my own changes didn't regress anything and the existing comparison content online is either stale or contradictory. It runs the same scenarios across every major virtualization library via Playwright, reports medians across runs, and is fully reproducible: `cd benchmarks && pnpm bench`. The bench is in the repo if you want to see it.

0 commit comments

Comments
 (0)