Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 84 additions & 3 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
# Virtualization benchmarks

Reproducible browser benchmarks comparing **@tanstack/react-virtual**, **virtua**, **react-virtuoso**, and **react-window** v2.
Reproducible browser benchmarks comparing **@tanstack/react-virtual**, **virtua**, **react-virtuoso**, **react-window** v2, and **react-aria-components** `Virtualizer`.

Same data, same scenarios, same harness — driven by Playwright against a real browser running a real Vite-built React app for each library.

## Library matrix

| id | What it measures |
| --- | --- |
| `tanstack` | Headless `@tanstack/react-virtual` + plain DOM rows |
| `tanstack-rac` | TanStack virtual + `role="listbox"` / `role="option"` (no RAC collection) |
| `rac-listbox` | React Aria `ListBox` only — collection overhead, all items in DOM |
| `rac` | React Aria `Virtualizer` + `ListBox` — full integrated stack |
| `virtua`, `virtuoso`, `window` | Existing third-party baselines |

`rac-listbox` skips `mount-fixed-100k` (100k DOM nodes is intentionally out of scope).

**RAC accuracy scenarios:** React Aria's virtualizer does not expose `scrollToIndex`. The harness uses layout-derived `scrollTop` + native scroll events. If the target row is not mounted after scroll settles, `landingErrorPx` is `-1` (item missing from DOM). `rac-listbox` (non-virtualized) reports accurate landings because every row stays in the DOM.

## Running

```bash
Expand Down Expand Up @@ -74,7 +88,7 @@ it's measuring — it just calls one global function per page.
| `jankMs` | Sum of frame durations > 50 ms during the action. |
| `memoryBytes` | `performance.memory.usedJSHeapSize` after the scenario. Chromium only; ungated by `--enable-precise-memory-info`. |

## Latest results (medians of 5 runs each)
## Latest results — third-party libraries (medians of 5 runs each)

**Hardware**: Author's machine — see `results/<timestamp>.json` for run conditions.

Expand Down Expand Up @@ -140,6 +154,64 @@ it's measuring — it just calls one global function per page.
> the others — same root cause as the slow mount: we hold a `VirtualItem`
> object per item, while virtua holds two numbers per item.

## React Aria comparison (medians of 2 runs each)

Run with `--libs tanstack,tanstack-rac,rac,rac-listbox` on 2026-06-21. See
`results/LATEST.md` for the full table set.

### Mount time — `React.render` → commit (lower is better, ms)

| Scenario | tanstack | tanstack-rac | rac | rac-listbox |
| ------------------- | -------: | -----------: | ------: | ----------: |
| `mount-fixed-1k` | 3.7 | **1.0** | 9.6 | 7.8 |
| `mount-fixed-10k` | **2.4** | 1.5 | 20.0 | 25.5 |
| `mount-fixed-100k` | **5.8** | 4.2 | 175.2 | — |
| `mount-dynamic-1k` | 1.8 | **1.4** | 7.3 | 8.1 |
| `mount-dynamic-10k` | **4.9** | 5.2 | 26.2 | 26.3 |

> **What we see:** `tanstack-rac` (TanStack virtual + WAI-ARIA roles only) mounts
> as fast as headless TanStack. The full RAC stack (`rac`) pays 10–30× more at
> 10k because of collection + layout setup. `rac-listbox` (no virtualizer) is
> similar at 10k but skipped at 100k — 100k DOM nodes is out of scope.

### Dynamic measurement — commit → stable total size (lower is better, ms)

| Scenario | tanstack | tanstack-rac | rac | rac-listbox |
| ------------------- | -------: | -----------: | ----: | ----------: |
| `mount-dynamic-1k` | 124.8 | **122.6** | 3,004 | **108.1** |
| `mount-dynamic-10k` | 118.7 | 119.4 | 3,009 | **107.6** |

> **What we see:** RAC's virtualizer waits ~3 s for layout to settle (its
> `isFullyMeasured` gate). Non-virtualized `rac-listbox` is fastest here because
> every row is already in the DOM — no scroll-range estimation needed.

### Memory after mount (lower is better, MB)

| Scenario | tanstack | tanstack-rac | rac | rac-listbox |
| ------------------- | -------: | -----------: | ----: | ----------: |
| `mount-fixed-10k` | **3.3** | 6.3 | 13.9 | 534.0 |
| `mount-fixed-100k` | **9.9** | 12.9 | 99.7 | — |
| `mount-dynamic-10k` | **4.8** | 7.7 | 15.4 | 535.3 |

> **What we see:** `rac-listbox` at 10k uses ~80× more heap than virtualized
> libraries because all 10k React nodes stay mounted. RAC virtualized is ~2×
> TanStack at 10k, ~10× at 100k.

### scrollToIndex landing accuracy — px offset from target (lower is better)

| Scenario | tanstack | tanstack-rac | rac | rac-listbox |
| ----------------------------------------- | -------: | -----------: | ---: | ----------: |
| `jump-to-middle-accuracy-dynamic-10k` | 0 | 0 | −1 | 0 |
| `jump-to-last-accuracy-dynamic-10k` | 0 | 0 | −1 | 0 |
| `jump-while-measuring-accuracy-dynamic-10k` | 0 | 0 | −1 | 0 |
| `jump-wide-variance-accuracy-10k` | 0 | 0 | −1 | 0 |

> **What we see:** `−1` means the target row was not in the DOM after scroll
> settled — RAC has no public `scrollToIndex`, so the harness uses layout-derived
> `scrollTop`. `rac-listbox` reports 0 px because every row stays mounted.
> Scroll FPS and jump-to-end settle time were identical across all four (60 fps,
> ~65–85 ms).

## Bottom line

- **Small-to-medium variable-size lists** (the most common use case) —
Expand All @@ -152,13 +224,21 @@ it's measuring — it just calls one global function per page.
libraries sustain 60 fps with zero dropped frames.
- **Jump-to-index** — react-window leads, TanStack lands ~15 ms slower,
virtuoso 2× slower than the leader.
- **React Aria overhead** — WAI-ARIA roles alone (`tanstack-rac`) add negligible
cost. The RAC collection + virtualizer stack adds ~10–30× mount time at 10k and
~30× at 100k vs headless TanStack. Non-virtualized `ListBox` is unusable at
scale (~534 MB heap at 10k items).
- **RAC accuracy** — virtualized RAC cannot satisfy `scrollToIndex` accuracy
probes today (`landingErrorPx = −1`); non-virtualized `rac-listbox` is exact.

## Notes on fairness

- Each page is implemented with the library's _recommended_ API. For example,
TanStack uses `useVirtualizer` + `measureElement`; virtua uses `VList` with
the `data`/`item` props; virtuoso uses `Virtuoso` with `fixedItemHeight`
when applicable; react-window uses `List` + `useDynamicRowHeight`.
when applicable; react-window uses `List` + `useDynamicRowHeight`; RAC uses
`Virtualizer` + `ListLayout` + `ListBox`; `tanstack-rac` adds listbox/option
roles to TanStack rows without RAC's collection layer.
- React 18 runs in production mode (no `<StrictMode>`).
- Dataset is deterministic (LCG-seeded) and identical across libraries.
- `--enable-precise-memory-info` + `--js-flags=--expose-gc` are passed to
Expand All @@ -177,6 +257,7 @@ Add an entry to `SCENARIOS` in `src/scenarios/types.ts`. The runner discovers it
1. Create `src/pages/MyLibPage.tsx` that registers a `HarnessHandle` (see existing pages for the contract).
2. Wire it into `src/main.tsx`'s switch.
3. Add the library name to `ALL_LIBS` in `runner/run.mjs`.
4. If a library cannot run certain scenarios, add exclusions to `LIB_SCENARIO_EXCLUSIONS` in `src/scenarios/libScenarioExclusions.mjs` (also re-exported from `scenarios/types.ts`).

## Known limitations

Expand Down
1 change: 1 addition & 0 deletions benchmarks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"dependencies": {
"@tanstack/react-virtual": "workspace:*",
"react": "^19.2.7",
"react-aria-components": "^1.19.0",
"react-dom": "^19.2.7",
"react-virtuoso": "^4.15.0",
"react-window": "^2.2.4",
Expand Down
15 changes: 13 additions & 2 deletions benchmarks/runner/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,22 @@ import { setTimeout as sleep } from 'node:timers/promises'
import { writeFileSync, mkdirSync } from 'node:fs'
import path from 'node:path'
import url from 'node:url'
import { LIB_SCENARIO_EXCLUSIONS } from '../src/scenarios/libScenarioExclusions.mjs'

const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
const BENCH_DIR = path.resolve(__dirname, '..')
const PORT = 4173
const BASE = `http://localhost:${PORT}`

const ALL_LIBS = ['tanstack', 'virtua', 'virtuoso', 'window']
const ALL_LIBS = [
'tanstack',
'tanstack-rac',
'virtua',
'virtuoso',
'window',
'rac',
'rac-listbox',
]
const ALL_SCENARIOS = [
'mount-fixed-1k',
'mount-fixed-10k',
Expand Down Expand Up @@ -260,7 +269,9 @@ async function main() {

const results = []
for (const lib of opts.libs) {
for (const scenarioId of opts.scenarios) {
const excluded = LIB_SCENARIO_EXCLUSIONS[lib] ?? []
const scenarios = opts.scenarios.filter((id) => !excluded.includes(id))
for (const scenarioId of scenarios) {
for (let r = 0; r < opts.runs; r++) {
process.stderr.write(
`\n ${lib.padEnd(9)} ${scenarioId.padEnd(28)} run ${r + 1}/${opts.runs} ... `,
Expand Down
33 changes: 21 additions & 12 deletions benchmarks/src/lib/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type { ScenarioInput, ScenarioMetrics } from '../scenarios/types'
export interface HarnessHandle {
/** Container element the library is told to scroll. */
getScrollContainer: () => HTMLElement | null
/** Optional wider DOM root for accuracy probes (e.g. virtualized item wrappers). */
getSearchRoot?: () => HTMLElement | null
/** Programmatically scroll to a target offset (px). */
scrollToOffset?: (offset: number) => void
/** Programmatically scroll to a target index. Some libraries expose
Expand Down Expand Up @@ -44,6 +46,21 @@ function nextFrame(): Promise<number> {
return new Promise((resolve) => requestAnimationFrame(resolve))
}

async function waitForTargetItem(
searchRoot: HTMLElement,
targetIndex: number,
timeoutMs = 2000,
): Promise<HTMLElement | null> {
const selector = `[data-index="${targetIndex}"]`
const start = performance.now()
while (performance.now() - start < timeoutMs) {
const el = searchRoot.querySelector(selector) as HTMLElement | null
if (el) return el
await nextFrame()
}
return searchRoot.querySelector(selector) as HTMLElement | null
}

function waitFor<T>(
predicate: () => T | false | null | undefined,
timeoutMs = 8000,
Expand Down Expand Up @@ -203,13 +220,8 @@ export function installBenchAPI(): void {
}
actionMs = performance.now() - t0

// Now: find the DOM element for the target index. Its viewport-relative
// top tells us where it actually landed. With align:'start', we want
// item[targetIndex]'s top to be at viewport top — i.e., offset 0.
const itemSelector = `[data-index="${targetIndex}"]`
const itemEl = container.querySelector(
itemSelector,
) as HTMLElement | null
const searchRoot = h.getSearchRoot?.() ?? container
const itemEl = await waitForTargetItem(searchRoot, targetIndex)
if (itemEl) {
const itemRect = itemEl.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
Expand Down Expand Up @@ -269,11 +281,8 @@ export function installBenchAPI(): void {
}
actionMs = performance.now() - t0

// Compute landing error: distance between the relevant edge of the
// target item and the relevant edge of the viewport.
const itemEl = container.querySelector(
`[data-index="${targetIndex}"]`,
) as HTMLElement | null
const searchRoot = h.getSearchRoot?.() ?? container
const itemEl = await waitForTargetItem(searchRoot, targetIndex)
if (itemEl) {
const iRect = itemEl.getBoundingClientRect()
const cRect = container.getBoundingClientRect()
Expand Down
26 changes: 26 additions & 0 deletions benchmarks/src/lib/itemRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Item } from './dataset'

export function ItemRow({
item,
itemSize,
dynamic,
index,
}: {
item: Item
itemSize: number
dynamic: boolean
index: number
}) {
return (
<div
data-index={index}
className={'item ' + (index % 2 === 0 ? 'even' : '')}
style={{
minHeight: dynamic ? undefined : itemSize,
boxSizing: 'border-box',
}}
>
{item.text}
</div>
)
}
114 changes: 114 additions & 0 deletions benchmarks/src/lib/racBench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { ListLayout } from 'react-aria-components'
import type { HarnessHandle } from './harness'
import type { ScenarioInput } from '../scenarios/types'
import type { Item } from './dataset'

export function findRacScrollContainer(
root: HTMLElement | null,
): HTMLElement | null {
if (!root) return null
for (const node of root.querySelectorAll('div')) {
const el = node as HTMLElement
const oy = getComputedStyle(el).overflowY
if (oy === 'auto' || oy === 'scroll') return el
}
return root
}

export function scrollRacToIndex(
container: HTMLElement,
layout: ListLayout<unknown> | null,
items: Array<Item>,
index: number,
itemSize: number,
align: 'start' | 'end' = 'start',
): void {
const key = items[index]?.id ?? index

for (let attempt = 0; attempt < 4; attempt++) {
let scrollTop = index * itemSize

if (layout?.virtualizer) {
layout.update({})
layout.getLayoutInfo(key)
const layoutInfo = layout.getLayoutInfo(key)
if (layoutInfo) {
scrollTop =
align === 'end'
? layoutInfo.rect.y + layoutInfo.rect.height - container.clientHeight
: layoutInfo.rect.y
}
} else if (align === 'end') {
scrollTop += itemSize - container.clientHeight
}

const maxScroll = Math.max(
0,
container.scrollHeight - container.clientHeight,
)
container.scrollTop = Math.max(0, Math.min(scrollTop, maxScroll))
container.scrollTo({ top: container.scrollTop, behavior: 'instant' })
container.dispatchEvent(new Event('scroll', { bubbles: true }))
}
}

export function createRacVirtualHarness({
hostRef,
scrollerRef,
layoutRef,
items,
scenario,
}: {
hostRef: { current: HTMLDivElement | null }
scrollerRef: { current: HTMLElement | null }
layoutRef: { current: ListLayout<unknown> | null }
items: Array<Item>
scenario: ScenarioInput
}): Omit<HarnessHandle, 'getScrollContainer'> & {
getScrollContainer: () => HTMLElement | null
} {
return {
getScrollContainer: () =>
scrollerRef.current ?? findRacScrollContainer(hostRef.current),
getSearchRoot: () => hostRef.current,
scrollToIndex: (index, opts) => {
const container =
scrollerRef.current ?? findRacScrollContainer(hostRef.current)
if (!container) return
scrollRacToIndex(
container,
layoutRef.current,
items,
index,
scenario.itemSize,
opts?.align ?? 'start',
)
},
getTotalSize: () => {
const container =
scrollerRef.current ?? findRacScrollContainer(hostRef.current)
if (!container) return 0
const content = container.firstElementChild as HTMLElement | null
return content?.offsetHeight ?? container.scrollHeight
},
isFullyMeasured: () => {
if (!scenario.dynamic) return true
const container =
scrollerRef.current ?? findRacScrollContainer(hostRef.current)
const total = container?.scrollHeight ?? 0
const estimate = scenario.count * scenario.itemSize
return total > 0 && total !== estimate
},
}
}

export function cacheRacScroller(
host: HTMLDivElement | null,
scrollerRef: { current: HTMLElement | null },
): void {
const scroller = findRacScrollContainer(host)
if (scroller) {
scrollerRef.current = scroller
scroller.dataset.benchRacScroller = 'true'
}
}
Loading