Skip to content
Merged
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
1 change: 1 addition & 0 deletions .claude/scheduled_tasks.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"sessionId":"e402bf71-ca74-4aa5-856c-da0c2053caab","pid":78596,"procStart":"Sat May 16 20:13:35 2026","acquiredAt":1779000018499}
6 changes: 6 additions & 0 deletions benchmarks/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules
dist
results/*.json
!results/SAMPLE.json
!results/.gitkeep
results/LATEST.md
8 changes: 8 additions & 0 deletions benchmarks/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# @tanstack/virtual-benchmarks

## 0.0.1

### Patch Changes

- Updated dependencies [[`99355ad`](https://github.com/TanStack/virtual/commit/99355ad1eceee6270efaa26e51f535d8d7c31ac2)]:
- @tanstack/react-virtual@3.13.25
189 changes: 189 additions & 0 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# Virtualization benchmarks

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

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

## Running

```bash
# from the repo root
pnpm install
pnpm --filter @tanstack/virtual-core build
cd benchmarks
pnpm exec playwright install chromium

# Full matrix, 5 runs per cell (~10 min)
pnpm bench

# Quick subset
pnpm bench -- --runs 2 --libs tanstack,virtua --scenarios mount-fixed-10k

# Watch the browser as it runs
pnpm bench:headed
```

Results land in `benchmarks/results/<timestamp>.json` (raw, every run) and
`benchmarks/results/LATEST.md` (median table from the last run).

## How it works

```text
benchmarks/
├── src/
│ ├── main.tsx Reads ?lib=... &scenario=...
│ ├── pages/ One file per library; all share the same harness
│ ├── lib/
│ │ ├── dataset.ts Deterministic item generator (LCG-seeded)
│ │ └── harness.ts Installs window.bench.run() that every page uses
│ └── scenarios/types.ts The fixed scenario list. Adding a row here
│ surfaces it in every library and the runner.
├── runner/run.mjs Playwright driver. Boots a server, runs each
│ (lib × scenario × run), aggregates medians.
├── results/ JSON snapshots + LATEST.md
└── package.json
```

Every library page mounts an identical dataset, registers a `HarnessHandle`,
and exposes the same `window.bench.run(scenario)` entrypoint that returns
`ScenarioMetrics`. That means the runner doesn't know or care which library
it's measuring — it just calls one global function per page.

## Scenarios

| id | items | size | dynamic | action |
| ------------------------- | ------- | ------ | ------- | ------------------------------------------------------------------- |
| `mount-fixed-1k` | 1,000 | 30 px | no | idle (just mount) |
| `mount-fixed-10k` | 10,000 | 30 px | no | idle |
| `mount-fixed-100k` | 100,000 | 30 px | no | idle |
| `mount-dynamic-1k` | 1,000 | varies | yes | wait for total size to settle |
| `mount-dynamic-10k` | 10,000 | varies | yes | wait for total size to settle |
| `scroll-to-bottom-10k` | 10,000 | 30 px | no | rAF-driven scroll, 1.5 s |
| `fast-scroll-dynamic-10k` | 10,000 | varies | yes | rAF-driven scroll, 1.5 s |
| `jump-to-end-dynamic-10k` | 10,000 | varies | yes | `scrollToIndex(9999)` then wait until scrollTop stable for 5 frames |

## Metrics

| field | meaning |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mountMs` | `React.render(...)` call → `useEffect` runs (commit complete). |
| `firstPaintMs` | `React.render(...)` call → one rAF after commit (≈ first paint). |
| `actionMs` | Action-specific. For scroll actions, total elapsed during the scripted scroll. For dynamic-measure, time from mount to a stable `getTotalSize()` (8 consecutive frames unchanged). For jump-to-end, time from `scrollToIndex` to position stable for 5 frames. |
| `scrollFps` | Average FPS sampled during the scripted scroll. |
| `longFrames` | Count of frames with inter-frame gap > 32 ms. |
| `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)

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

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

| Scenario | tanstack | virtua | virtuoso | window |
| ------------------- | -------: | ------: | -------: | -----: |
| `mount-fixed-1k` | **0.8** | 0.7 | 1.8 | 2.2 |
| `mount-fixed-10k` | 1.6 | **1.0** | 2.0 | 2.4 |
| `mount-fixed-100k` | 6.1 | **3.1** | 5.0 | 4.4 |
| `mount-dynamic-1k` | **1.5** | 1.8 | 2.8 | 2.9 |
| `mount-dynamic-10k` | **6.0** | 6.7 | 8.5 | 7.0 |

> **What we see:** TanStack is fastest on every scenario at 1k–10k items, but
> _slowest_ at 100k fixed. The audit predicted this: we eagerly populate
> `measurementsCache` (one object per item) on every mount, while virtua's
> lazy prefix-sum cache only does work for the visible window.

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

| Scenario | tanstack | virtua | virtuoso | window |
| ------------------- | -------: | ------: | -------: | ------: |
| `mount-dynamic-1k` | 124 | **121** | 194 | 122 |
| `mount-dynamic-10k` | 118 | 118 | 188 | **116** |

> **What we see:** Roughly tied between TanStack, virtua, and react-window.
> Virtuoso takes ~60% longer because its scroll-anchoring keeps adjusting
> the inner spacer for several frames after the initial measurement pass.

### Scroll perf — fps & long frames during 1.5 s programmatic scroll

| Scenario | tanstack | virtua | virtuoso | window |
| ------------------------------------ | -------: | -----: | -------: | -----: |
| `scroll-to-bottom-10k` fps | 60 | 60 | 60 | 60 |
| `fast-scroll-dynamic-10k` fps | 60 | 60 | 60 | 60 |
| `scroll-to-bottom-10k` longFrames | 0 | 0 | 0 | 0 |
| `fast-scroll-dynamic-10k` longFrames | 0 | 0 | 0 | 0 |

> **Caveat:** at 10k items, none of these libraries even break a sweat.
> A 1.5 s rAF-paced scroll is too gentle to expose perf differences. Real
> stress tests would need expensive item renderers and/or 100k+ items.

### Jump-to-end settle time (lower is better, ms)

| Scenario | tanstack | virtua | virtuoso | window |
| ------------------------- | -------: | -----: | -------: | -----: |
| `jump-to-end-dynamic-10k` | 83 | 72 | 154 | **68** |

> **What we see:** react-window is fastest. TanStack lands 15 ms behind, likely
> from the `requestAnimationFrame` reconcile loop running an extra frame or
> two before declaring the position stable. Virtuoso is 2× slower than the
> fastest because its anchoring + measurement loop takes longer to converge.

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

| Scenario | tanstack | virtua | virtuoso | window |
| ------------------- | -------: | -------: | -------: | -----: |
| `mount-fixed-10k` | 6.6 | **6.4** | 6.7 | 7.0 |
| `mount-fixed-100k` | 14.2 | **10.5** | 10.8 | 11.1 |
| `mount-dynamic-10k` | 8.1 | **7.8** | 8.8 | 8.5 |

> **What we see:** Tight at 10k. At 100k fixed, TanStack uses ~3 MB more than
> the others — same root cause as the slow mount: we hold a `VirtualItem`
> object per item, while virtua holds two numbers per item.

## Bottom line

- **Small-to-medium variable-size lists** (the most common use case) —
TanStack is consistently the fastest to mount, tied on dynamic measurement,
competitive on memory.
- **Huge fixed-size lists (100k+ items)** — virtua wins decisively on mount
time and memory because its lazy prefix-sum cache only materializes the
visible window. TanStack's eager `measurementsCache` is the cost.
- **Scroll perf** — at the list sizes / workloads tested, all four
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.

## 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`.
- 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
Chromium so memory readings aren't bucketed and we can force GC between
runs.
- Medians across 5 runs are reported (raw runs in `results/<ts>.json`).
- Run on a built (`vite build`) preview server, not the dev server — so we
measure production code paths.

## Adding a scenario

Add an entry to `SCENARIOS` in `src/scenarios/types.ts`. The runner discovers it automatically.

## Adding a library

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

## Known limitations

- Scroll tests are programmatic (rAF-driven) and at the tested list sizes,
every library trivially hits 60 fps. A harder test would render expensive
items, scroll faster, or both. PRs welcome.
- Memory deltas at small list sizes (≤10k items) are within the noise floor
of `performance.memory`.
- Single-machine numbers. The _shape_ of the comparison transfers across
machines, the absolute values don't.
36 changes: 36 additions & 0 deletions benchmarks/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Virtualization benchmarks</title>
<style>
html,
body,
#root {
margin: 0;
padding: 0;
height: 100%;
font-family: -apple-system, sans-serif;
}
.scroll-host {
height: 600px;
width: 600px;
overflow: auto;
box-sizing: border-box;
}
.item {
border-bottom: 1px solid #eee;
padding: 6px 10px;
box-sizing: border-box;
}
.item.even {
background: #fafafa;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
29 changes: 29 additions & 0 deletions benchmarks/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@tanstack/virtual-benchmarks",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 4173",
"bench": "node runner/run.mjs",
"bench:headed": "node runner/run.mjs --headed"
},
"dependencies": {
"@tanstack/react-virtual": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-virtuoso": "^4.15.0",
"react-window": "^2.2.4",
"virtua": "^0.49.0"
},
"devDependencies": {
"@playwright/test": "^1.53.1",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.5.2",
"typescript": "5.6.3",
"vite": "^6.4.2"
}
}
Empty file added benchmarks/results/.gitkeep
Empty file.
Loading
Loading