The apps, generators, and bench runner in this repository were generated with Claude Fable 5 and reviewed by the Vitest team. The results below were measured by hand on the hardware stated in each section.
Generated reference apps for measuring Vitest performance. Each app stands for one kind of project: a tiny utility package, a library, barrel-file graphs, DOM component suites, a dependency-heavy service, a 1300-module monolith, and a long-running DOM suite. The bench runner measures the options that change run time on each of them: pool, environment (jsdom, happy-dom, and headless Chromium via browser mode), isolate, fsModuleCache, maxWorkers, and cold vs warm caches.
pnpm install
pnpm --dir apps/react-spa exec playwright install chromium # for the browser cells
pnpm generate # writes apps/*/src and apps/*/tests (gitignored, deterministic)
pnpm bench # default matrix, every app, 3 reps per cell
pnpm bench --apps react-spa,barrel-hell --runs 5
pnpm bench --matrix quick --runs 1 # one representative cell per app
# A/B a local vitest build against the pinned release
pnpm bench --label main # 1. measure the pinned release first
# 2. link the local build (see below), then
pnpm bench --label branch
pnpm compare results/main.json results/branch.jsonLink the local build into the workspace instead of pointing --vitest at its binary. Dependencies such as @testing-library/jest-dom/vitest import vitest themselves, and Node resolves that import to the pinned install. With --vitest alone the tests use one expect and the matchers register on another, so every jsdom cell fails with Invalid Chai property. The browser cells also need the matching @vitest/browser-playwright.
Add overrides to pnpm-workspace.yaml (do not commit them):
overrides:
vitest: link:/path/to/vitest/packages/vitest
"@vitest/browser-playwright": link:/path/to/vitest/packages/browser-playwright
"@vitest/coverage-v8": link:/path/to/vitest/packages/coverage-v8
"@vitest/coverage-istanbul": link:/path/to/vitest/packages/coverage-istanbulThen reinstall and add the link at the workspace root. Packages inside node_modules/.pnpm resolve vitest from there:
pnpm install
ln -sfn /path/to/vitest/packages/vitest node_modules/vitest
pnpm bench --label branch # picks up the linked build, prints its versionRun pnpm build in the vitest repository before measuring, because the link points at dist/. The linked build resolves vite from the vitest repository, not from the pin in this workspace, so check that both versions match before comparing Vite-sensitive cells. To return to the pinned release, remove the overrides and the symlink and run pnpm install again.
| option | values | default |
|---|---|---|
--apps |
comma-separated app names | all apps |
--matrix |
quick (1-2 cells per app), default (curated cells below), full (whole cross product, use with --apps) |
default |
--runs |
timed reps per cell, median reported | 3 |
--label |
name of the result file, results/<label>.json |
local |
--vitest |
path to a vitest.mjs binary (or VITEST_BIN env); only changes the binary, see Benchmarking a local vitest build |
the pinned install |
BENCH_FS_CACHE_MODE |
stable | experimental, where the fs-cache option lives; auto-detected from the vitest version |
auto |
cold cells wipe every persistent cache (Vite deps and transform caches, vitest cache dirs, fs module cache) before each timed rep. This is what a fresh CI run pays. warm cells wipe once, run once untimed to prime the caches, then measure. This is what repeated local runs pay. The host's NODE_COMPILE_CACHE is cleared in both cases; whatever a vitest version enables itself is part of its measurement.
Every app is a normal standalone Vitest project. The committed configs read BENCH_* variables (see tools/config/bench-config.js), so any cell can be reproduced with plain vitest run:
cd apps/design-system
BENCH_POOL=vmThreads BENCH_ENV=happy-dom BENCH_ISOLATE=false pnpm test
BENCH_BROWSER=true pnpm test # headless Chromium| variable | values |
|---|---|
BENCH_POOL |
forks, threads, vmThreads, vmForks |
BENCH_ENV |
node, jsdom, happy-dom |
BENCH_ISOLATE |
true, false |
BENCH_FS_CACHE |
true, false (with BENCH_FS_CACHE_MODE=experimental on vitest ≤ 4.1) |
BENCH_MAX_WORKERS |
a number or a percentage like 50% |
BENCH_FILE_PARALLELISM |
true, false |
BENCH_COVERAGE |
v8, istanbul |
BENCH_BROWSER |
true, headless Chromium via playwright (react-spa, vue-spa, design-system) |
pnpm bench:coverage measures coverage on the fastest warm row of every app (the best row in scripts/matrix.mjs): once without coverage, once with @vitest/coverage-v8, once with @vitest/coverage-istanbul. It takes --apps, --runs, --label, and --vitest like bench.
pnpm bench:coverage --label vitest-4.1.10-coverage
# link the local build, then
pnpm bench:coverage --label vitest-5.0-coverage
node scripts/render-coverage.mjs results/vitest-4.1.10-coverage.json results/vitest-5.0-coverage.jsonThe reporter's Duration line covers the tests only, so the tables report the whole-process wall clock. The runner also sets DEBUG=vitest:coverage and stores the provider's Generate coverage total time in the result file (generate), together with the ast-v8-to-istanbul version under @vitest/coverage-v8 (meta.astV8ToIstanbul).
Apple M4 (10 cores), node v24.13.0. Whole-process wall clock of vitest run, median of 3 reps, both versions measured on the same machine in one session. 4.1.10 is the pinned install on vite 8.1.4. 5.0.0 was measured from a local build linked as described above, on vite 8.0.11. The row in bold is the app's fastest warm row; the coverage tables below measure coverage on it. To regenerate: pnpm bench --label vitest-4.1.10, then pnpm bench --label vitest-5.0 with the linked build, then node scripts/render-results.mjs results/vitest-4.1.10.json results/vitest-5.0.json.
The median open source package: 8 modules, 5 test files, no dependencies. Startup overhead is everything here. The jsdom and happy-dom rows show what a DOM environment costs a node-only suite.
| pool | env | isolate | 4.1.10 cold | 5.0.0 cold | Δ | 4.1.10 warm | 5.0.0 warm | Δ |
|---|---|---|---|---|---|---|---|---|
| forks | node | true | 0.27s | 0.25s | −5% | 0.27s | 0.25s | −6% |
| forks | node | false | 0.26s | 0.25s | −5% | 0.26s | 0.26s | −3% |
| threads | node | true | 0.25s | 0.24s | −6% | 0.25s | 0.24s | −6% |
| threads | node | false | 0.25s | 0.24s | −6% | 0.25s | 0.24s | −4% |
| vmThreads | node | true | 0.39s | 0.37s | −3% | 0.28s | 0.27s | −3% |
| vmThreads | node | false | 0.39s | 0.39s | ~0 | 0.28s | 0.28s | ~0 |
| vmForks | node | true | 0.38s | 0.38s | ~0 | 0.29s | 0.28s | −3% |
| vmForks | node | false | 0.38s | 0.38s | ~0 | 0.30s | 0.28s | −4% |
| forks | jsdom | true | — | — | — | 0.63s | 0.58s | −8% |
| forks | happy-dom | true | — | — | — | 0.41s | 0.39s | −6% |
A mid-size library: 127 modules in 3 layers and 40 test files. Each test file imports the modules it tests directly, so the per-file graphs overlap little.
| pool | env | isolate | fsModuleCache | 4.1.10 cold | 5.0.0 cold | Δ | 4.1.10 warm | 5.0.0 warm | Δ |
|---|---|---|---|---|---|---|---|---|---|
| forks | node | true | false | 0.88s | 0.80s | −9% | 0.86s | 0.75s | −13% |
| forks | node | true | true | 0.89s | 0.77s | −13% | 0.78s | 0.65s | −17% |
| forks | node | false | false | 0.46s | 0.43s | −7% | 0.45s | 0.42s | −8% |
| forks | node | false | true | 0.46s | 0.43s | −8% | 0.33s | 0.31s | −7% |
| threads | node | true | false | 0.74s | 0.64s | −13% | 0.74s | 0.65s | −13% |
| threads | node | true | true | 0.78s | 0.67s | −14% | 0.68s | 0.55s | −20% |
| threads | node | false | false | 0.39s | 0.37s | −4% | 0.39s | 0.38s | −3% |
| threads | node | false | true | 0.42s | 0.40s | −6% | 0.30s | 0.28s | −6% |
| forks | jsdom | true | false | — | — | — | 3.40s | 3.14s | −8% |
An API service on express 5, zod, pino, dayjs, and lodash. 16 integration-style test files with real work per test: hundreds of validations and CRUD flows over in-memory repositories.
| pool | isolate | 4.1.10 cold | 5.0.0 cold | Δ | 4.1.10 warm | 5.0.0 warm | Δ |
|---|---|---|---|---|---|---|---|
| forks | true | 0.64s | 0.59s | −8% | 0.64s | 0.59s | −8% |
| forks | false | — | — | — | 0.47s | 0.55s | +18% |
| threads | true | — | — | — | 0.56s | 0.58s | +4% |
| threads | false | — | — | — | 0.44s | 0.42s | −4% |
| vmThreads | true | — | — | — | 0.51s | 0.47s | −8% |
| vmThreads | false | — | — | — | 0.51s | 0.47s | −9% |
| vmForks | true | — | — | — | 0.55s | 0.51s | −8% |
| vmForks | false | — | — | — | 0.55s | 0.50s | −8% |
Thin code over 10 real packages that cover the module shapes that matter: CJS monoliths (lodash, semver), many-file ESM graphs (lodash-es, date-fns, rxjs), one big ESM file (zod), and dual packages (yaml, uuid). Node pools load externals once per worker; vm pools evaluate them again in each context.
| pool | isolate | 4.1.10 cold | 5.0.0 cold | Δ | 4.1.10 warm | 5.0.0 warm | Δ |
|---|---|---|---|---|---|---|---|
| forks | true | 2.11s | 2.05s | −2% | 2.20s | 2.10s | −5% |
| forks | false | — | — | — | 1.24s | 1.23s | ~0 |
| threads | true | — | — | — | 2.07s | 1.99s | −4% |
| threads | false | — | — | — | 1.22s | 1.18s | −3% |
| vmThreads | true | 1.55s | 0.72s | −54% | 1.59s | 0.70s | −56% |
| vmThreads | false | — | — | — | 1.57s | 0.69s | −56% |
| vmForks | true | — | — | — | 1.65s | 0.73s | −55% |
| vmForks | false | — | — | — | 1.65s | 0.75s | −55% |
A React SPA tested with Testing Library: 92 ts/tsx modules in 6 features, CSS and CSS modules, hooks, a mocked API layer, and a jest-dom setup file. Runs in jsdom, happy-dom, and real Chromium.
| pool | env | isolate | fsModuleCache | 4.1.10 cold | 5.0.0 cold | Δ | 4.1.10 warm | 5.0.0 warm | Δ |
|---|---|---|---|---|---|---|---|---|---|
| forks | jsdom | true | false | 3.10s | 3.16s | +2% | 3.08s | 3.12s | ~0 |
| forks | jsdom | false | false | — | — | — | 1.10s | 1.12s | +2% |
| forks | happy-dom | true | false | — | — | — | 1.94s | 1.97s | ~0 |
| forks | happy-dom | false | false | — | — | — | 0.77s | 0.77s | ~0 |
| threads | jsdom | true | false | — | — | — | 2.83s | 2.75s | −3% |
| threads | jsdom | false | false | — | — | — | 1.03s | 1.01s | ~0 |
| threads | happy-dom | true | false | — | — | — | 1.76s | 1.66s | −6% |
| threads | happy-dom | false | false | — | — | — | 0.71s | 0.70s | −2% |
| vmThreads | jsdom | true | false | — | — | — | 1.25s | 1.07s | −15% |
| vmThreads | jsdom | false | false | — | — | — | 1.27s | 1.10s | −13% |
| vmThreads | happy-dom | true | false | — | — | — | 0.98s | 0.87s | −12% |
| vmThreads | happy-dom | false | false | — | — | — | 0.97s | 0.85s | −12% |
| forks | jsdom | true | true | 3.07s | 3.12s | +2% | 3.00s | 3.09s | +3% |
| browser | chromium | true | false | 2.43s | 2.06s | −15% | 2.40s | 2.01s | −16% |
37 single-file components plus composables, tested with @vue/test-utils. SFC compilation through @vitejs/plugin-vue makes this the expensive-transform app.
| pool | env | isolate | 4.1.10 cold | 5.0.0 cold | Δ | 4.1.10 warm | 5.0.0 warm | Δ |
|---|---|---|---|---|---|---|---|---|
| forks | jsdom | true | 2.13s | 2.08s | −2% | 2.13s | 2.05s | −4% |
| forks | jsdom | false | — | — | — | 1.08s | 1.07s | ~0 |
| forks | happy-dom | true | — | — | — | 1.29s | 1.27s | −2% |
| forks | happy-dom | false | — | — | — | 0.71s | 0.75s | +5% |
| threads | jsdom | true | — | — | — | 1.96s | 1.97s | ~0 |
| threads | jsdom | false | — | — | — | 1.00s | 1.03s | +3% |
| threads | happy-dom | true | — | — | — | 1.19s | 1.16s | −2% |
| threads | happy-dom | false | — | — | — | 0.70s | 0.69s | ~0 |
| browser | chromium | true | 2.03s | 1.60s | −21% | 1.94s | 1.58s | −18% |
80 components with per-component CSS. Every one of the 80 test files imports from the root barrel, so each file pays for the whole library plus a DOM environment.
| pool | env | isolate | 4.1.10 cold | 5.0.0 cold | Δ | 4.1.10 warm | 5.0.0 warm | Δ |
|---|---|---|---|---|---|---|---|---|
| forks | jsdom | true | 8.11s | 8.30s | +2% | 8.11s | 8.31s | +2% |
| forks | jsdom | false | — | — | — | 1.29s | 1.32s | +2% |
| forks | happy-dom | true | — | — | — | 5.22s | 5.15s | ~0 |
| forks | happy-dom | false | — | — | — | 0.96s | 0.98s | +3% |
| vmThreads | jsdom | true | — | — | — | 2.09s | 1.72s | −18% |
| vmThreads | jsdom | false | — | — | — | 2.09s | 1.70s | −19% |
| vmThreads | happy-dom | true | — | — | — | 1.81s | 1.47s | −19% |
| vmThreads | happy-dom | false | — | — | — | 1.80s | 1.46s | −19% |
| browser | chromium | true | 5.14s | 4.79s | −7% | 5.10s | 4.65s | −9% |
The same barrel problem without DOM or JSX: 817 modules behind nested barrels and 20 test files that use about 3 symbols each. Every file evaluates the full graph.
| pool | isolate | fsModuleCache | 4.1.10 cold | 5.0.0 cold | Δ | 4.1.10 warm | 5.0.0 warm | Δ |
|---|---|---|---|---|---|---|---|---|
| forks | true | false | 1.90s | 1.74s | −8% | 1.92s | 1.70s | −11% |
| forks | true | true | 2.06s | 1.72s | −16% | 1.33s | 1.08s | −18% |
| forks | false | false | 1.39s | 1.36s | −2% | 1.35s | 1.36s | ~0 |
| forks | false | true | 1.43s | 1.40s | −2% | 0.76s | 0.71s | −7% |
| threads | true | false | 1.26s | 1.27s | ~0 | 1.25s | 1.25s | ~0 |
| threads | true | true | 1.64s | 1.42s | −13% | 1.05s | 0.91s | −13% |
| threads | false | false | 0.92s | 0.93s | ~0 | 0.91s | 0.93s | +3% |
| threads | false | true | 1.18s | 1.15s | −2% | 0.63s | 0.58s | −8% |
A large monorepo: about 1280 modules with 12-deep import chains, import cycles, path aliases, dynamic imports, JSON imports, and 150 test files. 15 of them use a jsdom pragma, so mixed environments limit worker reuse.
| pool | isolate | fsModuleCache | maxWorkers | 4.1.10 cold | 5.0.0 cold | Δ | 4.1.10 warm | 5.0.0 warm | Δ |
|---|---|---|---|---|---|---|---|---|---|
| forks | true | false | default | 7.37s | 5.92s | −20% | 7.24s | 5.83s | −19% |
| forks | true | true | default | 7.32s | 5.99s | −18% | 6.36s | 5.16s | −19% |
| forks | false | false | default | 2.73s | 2.77s | +2% | 3.24s | 3.10s | −4% |
| forks | false | true | default | 2.85s | 2.85s | ~0 | 2.26s | 2.22s | −2% |
| threads | true | false | default | 5.36s | 5.25s | −2% | 5.32s | 5.15s | −3% |
| threads | true | true | default | 5.90s | 5.18s | −12% | 5.02s | 4.43s | −12% |
| threads | false | false | default | 2.12s | 2.16s | +2% | 2.49s | 2.42s | −3% |
| threads | false | true | default | 2.48s | 2.45s | ~0 | 1.90s | 1.98s | +5% |
| forks | false | false | 50% | — | — | — | 3.36s | 3.17s | −6% |
A long-running DOM suite: 80 jsdom test files through 2 workers, each file holding a 15MB module-level dataset and rendering tables over it. Node pools rebuild the environment and import the external dependencies again for each of a worker's 40 files. vm pool workers pay once, reuse compiled scripts across contexts, and get recycled by the pinned 512MB vmMemoryLimit several times per run. This is the app where the vm pools win by a wide margin. Memory retention across files is out of scope here.
| pool | env | 4.1.10 cold | 5.0.0 cold | Δ | 4.1.10 warm | 5.0.0 warm | Δ |
|---|---|---|---|---|---|---|---|
| forks | jsdom | — | — | — | 18.88s | 18.24s | −3% |
| threads | jsdom | — | — | — | 17.28s | 16.72s | −3% |
| vmThreads | jsdom | — | — | — | 5.82s | 5.13s | −12% |
| vmForks | jsdom | 6.02s | 5.15s | −15% | 6.03s | 5.13s | −15% |
| forks | happy-dom | — | — | — | 11.38s | 10.89s | −4% |
| vmForks | happy-dom | — | — | — | 5.43s | 4.06s | −25% |
30 test files that burn real CPU (hashing, sieving, matrix multiplication) on an 8-module graph. The tests dominate, so only scheduling (maxWorkers, pool choice) changes anything.
| pool | isolate | maxWorkers | 4.1.10 cold | 5.0.0 cold | Δ | 4.1.10 warm | 5.0.0 warm | Δ |
|---|---|---|---|---|---|---|---|---|
| forks | true | 25% | — | — | — | 1.55s | 1.43s | −8% |
| forks | true | 50% | — | — | — | 1.18s | 1.08s | −9% |
| forks | true | 100% | — | — | — | 0.94s | 0.88s | −7% |
| threads | true | 25% | — | — | — | 1.45s | 1.33s | −9% |
| threads | true | 50% | — | — | — | 1.09s | 1.01s | −8% |
| threads | true | 100% | — | — | — | 0.91s | 0.83s | −8% |
| forks | false | 100% | — | — | — | 0.66s | 0.63s | −4% |
Whole-process wall clock of vitest run on the bold row of each app above, median of 3 reps (design-system and long-haul: 5 reps), without coverage and with each provider. Same machine and protocol as above. 4.1.10 is the pinned install with @vitest/coverage-v8 4.1.10 on ast-v8-to-istanbul 1.0.4. 5.0.0 is the local build (5.0.0-rc.2 plus 41 commits, 130f79b8b) with its own @vitest/coverage-v8 on ast-v8-to-istanbul 1.0.5. Default coverage options: only files loaded by the tests are covered, and the text, html, clover, and json reports are written. To regenerate: pnpm bench:coverage --label vitest-4.1.10-coverage, then pnpm bench:coverage --label vitest-5.0-coverage with the linked build, then node scripts/render-coverage.mjs results/vitest-4.1.10-coverage.json results/vitest-5.0-coverage.json.
| app | 4.1.10 none | 5.0.0 none | Δ | 4.1.10 v8 | 5.0.0 v8 | Δ | 4.1.10 istanbul | 5.0.0 istanbul | Δ |
|---|---|---|---|---|---|---|---|---|---|
| micro-utils | 0.25s | 0.24s | −2% | 0.29s | 0.29s | ~0 | 0.39s | 0.38s | −4% |
| node-library | 0.29s | 0.28s | −3% | 0.53s | 0.51s | −4% | 0.65s | 0.56s | −14% |
| node-backend | 0.43s | 0.41s | −4% | 0.55s | 0.54s | ~0 | 0.69s | 0.66s | −5% |
| deps-heavy | 1.63s | 0.69s | −57% | 1.86s | 0.92s | −51% | 1.82s | 0.86s | −53% |
| react-spa | 0.70s | 0.69s | −2% | 0.87s | 0.87s | ~0 | 0.98s | 0.95s | −3% |
| vue-spa | 0.68s | 0.70s | +4% | 0.78s | 0.79s | ~0 | 0.99s | 0.93s | −7% |
| design-system | 0.93s | 0.92s | ~0 | 1.20s | 1.22s | +2% | 1.45s | 1.37s | −5% |
| barrel-hell | 0.63s | 0.57s | −9% | 1.51s | 1.29s | −15% | 2.40s | 1.69s | −30% |
| enterprise-monolith | 1.96s | 1.96s | ~0 | 4.97s | 4.07s | −18% | 10.84s | 6.88s | −36% |
| long-haul | 5.37s | 3.98s | −26% | 8.16s | 7.48s | −8% | 6.45s | 4.75s | −26% |
| cpu-bound | 0.65s | 0.63s | −4% | 1.00s | 1.00s | ~0 | 0.93s | 0.90s | −2% |
- Generators are deterministic: no randomness, the structure comes from modular arithmetic.
pnpm generateproduces byte-identical sources, so a generator diff is a reviewable change to an app's shape. - Every dependency is pinned exactly and the lockfile is committed. The vitest, vite, jsdom, happy-dom, and playwright pins are part of the measurement. Bump them on purpose, in their own commit.
- Tests assert real behavior computed through the import graph, so a vitest correctness regression fails the bench instead of timing broken runs.
- CI (
smoke.yml) only checks that every app generates and passes under the pinned vitest. Shared runners are too noisy for timing; use quiet dedicated hardware andcompare.mjsfor A/B decisions.