Why uf
Benchmarks
Every uf command timed on one generated application, beside the tool a project
would otherwise use for the same job, on one machine, in one run. Every number
below is read from the file that run wrote; none is typed in by hand. The rows
uf loses come first.
What you will be able to do: see where uf stands on each command, which
gaps are the largest, and reproduce any number on this page with one command.
What you need first: nothing. To reproduce the numbers: a checkout of this
repository with npm ci run, and Bun on PATH for the Bun rows.
Where the numbers come from
One command measured all of them:
$ npm ci --prefix tools/bench/toolchain/rivals # the comparison tools, pinned
$ uf run bench:toolchain -- --tools all --runs 7 --warmup 1
uf run bench:toolchain builds uf in release mode, writes the application
under the system's temporary directory, times every stage, prints a table and
writes a JSON file. tools/bench/toolchain/report.js documents that file. The
run below is one the Bench workflow made on a GitHub Actions runner, committed
as tools/bench/toolchain/baseline.json; this page renders that file when the
site is built.
A hosted runner is shared with other work, and a millisecond on it is not a
millisecond on your laptop. Read the ratios between rows of the same table, which
were measured minutes apart on the same machine, rather than the absolute
numbers. The load average before and after the run is below so you can see how
busy the machine was.
| Finished | 2026-09-23T06:34:14.101Z |
|---|
| Machine | AMD EPYC, 32 cores, 124.2 GB, linux 6.6.141 x64 |
|---|
| Runner | github-actions blacksmith-32vcpu-ubuntu-2404-Runner-6e8d7e42c4 (Linux X64, ubuntu24 20260121153938) |
|---|
| Load average | 0.51 0.11 0.04 before, 2.35 2 0.89 after |
|---|
| Runs | 7 timed runs of every stage after 1 thrown away; the median is shown |
|---|
| Command | uf run bench:toolchain -- --tools all --runs 7 --warmup 1 --work-dir /home/runner/_work/_temp/uf-bench --out /home/runner/_work/_temp/bench/results.json |
|---|
Fixture small | 10 routes, 31 components (11 client), 20 library modules, 200 tests in 20 files |
|---|
Fixture suite | 1000 tests in 50 files, 2000 assertions |
|---|
Fixture install | 18 direct dependencies of a scaffolded application, pinned; `uf install` ran npm |
|---|
| Tool or input | Version |
|---|
| uf | 0.0.0-alpha.46 |
| commit | 982f7923c7696c44945f5b0bccbf19457cb475e3 |
| binary | target/release/uf |
| node | v24.13.0 |
| npm | 11.6.2 |
| vite | 8.2.2 |
| react | 19.3.0 |
| host | node |
| vp | 1.0.0-rc.0 |
| next | 16.3.6 |
| vitest | 5.0.1 |
| bun | 1.3.14 |
| eslint | 10.11.0 |
| prettier | 3.9.8 |
| biome | 2.5.12 |
| flow | 0.332.0 |
| tsc | 6.0.3 |
| pnpm | 10.34.5 |
Where uf loses
Every stage in which another tool's median is lower than uf's. The table is
computed from the file, so it changes when the numbers do.
| Stage | uf | Faster |
|---|
| lint and type check, cold — small | uf check 659 ms | flow full-check 193 ms; tsc --noEmit 600 ms |
| test suite, cold — small | uf test 284 ms | bun test 27 ms |
| test suite, warm — small | uf test 74 ms | bun test 26 ms |
| production build, cold — small | uf build 2.55 s | vp build 296 ms |
| production build, warm — small | uf build 2.42 s | vp build 306 ms; next build 1.40 s |
| dev server start to first document, cold — small | uf dev 1.10 s | vp dev 241 ms; next dev 1.01 s |
| dev server start to first document, warm — small | uf dev 875 ms | vp dev 233 ms |
| edit to HMR update message — small | uf dev; edit components/HotCounter.js 2.4 ms | vp dev; edit src/components/HotCounter.tsx 0.9 ms |
| edit to updated module served — small | uf dev; edit components/HotCounter.js 9.0 ms | vp dev; edit src/components/HotCounter.tsx 2.5 ms |
| many small test files, cold — suite | uf test 446 ms | bun test 57 ms |
| many small test files, warm — suite | uf test 103 ms | bun test 60 ms |
| install, cold — install | uf install 7.28 s | bun install 478 ms; pnpm install 1.34 s |
| install, warm — install | uf install 906 ms | bun install 48 ms; pnpm install 411 ms |
Each tool runs on its own copy of the same application, written in the form a
project using that tool would have. tools/bench/toolchain/rivals.js generates
the copies; they share the preset's size, and the same modules and tests with
the same values and assertions.
| Copy | Written as | Tools |
|---|
| the uf application | Flow, component syntax, match, Flow enums, @uniflowed/test | uf |
| Vite | TypeScript and TSX, index.html and src/main.tsx, routes lazily imported, tests against vite-plus/test | vp dev, vp build, vp test, vp lint, eslint ., biome lint ., tsc --noEmit, tsgo --noEmit |
| Next.js | App Router: app/layout.tsx, app/rNNN/page.tsx, the toggles marked "use client" | next dev, next build |
| Vitest, Rstest, Bun | the library modules and their tests against vitest, @rstest/core and bun:test | vitest run, rstest run, bun test |
| one copy per formatter | the Vite copy, formatted by that formatter first | vp fmt --check, prettier --check ., biome format . |
| Flow | the uf application with a .flowconfig, mixed spelled unknown, and flow-typed stubs for @uniflowed/* | flow full-check |
| install | the pinned third-party dependencies of a scaffolded application | uf install, pnpm install, bun install |
| many small test files | 50 files × 20 cases × 2 assertions, from tools/bench/testing/suite.js | uf test, bun test, vitest run, rstest run |
Where a row is not the same work, it says so here rather than in a footnote:
vp dev's first document is an empty HTML shell. Vite serves
index.html and the browser requests the application afterwards; uf and
Next.js render the page on the server before answering. The row times what a
person waits for before the first byte, and it is not the same amount of work.
next dev has no HMR rows. Next.js pushes updates over a socket protocol
of its own, and the harness speaks Vite's. vp dev's HMR is measured the way
uf's is: from the file write to the update message, and to the edited module
being served.
flow full-check sees the @uniflowed/* packages as any, through
stubs, the way a package with no library definition is seen. uf check
checks against their real types and runs uf's linter first, so it does more
work. flow full-check is the whole-project check without a server, which is
what flow check was before it became a question put to a server.
tsc --noEmit is not in the issue's table, and is here because it is
what the TypeScript copies would be type-checked with.
tsgo --noEmit is TypeScript 7's native compiler, the Go port, pinned
as typescript@7 and run as the executable its tsc launcher starts. The
launcher is a Node script that only execves that executable, so timing it
would charge TypeScript for a Node start-up it does not need. tsc stays on
TypeScript 6, the JavaScript compiler, beside it.
- ESLint runs
@eslint/js and typescript-eslint's recommended rules, the
configuration npm init @eslint/config writes. Biome and Vite+ run with no
configuration of their own.
- Cold removes a tool's own caches before every run:
.uf, dist and
node_modules/.vite for uf, .next for Next.js, node_modules/.vite for Vite+
and Vitest, node_modules/.cache for Rstest. Prettier, ESLint, Biome, tsc
and tsgo keep no cache unless asked to,
so their cold and warm rows are the same command run twice. Warm keeps
what the previous run left behind.
- CPU is the user and system time of the command and every process it
waited for. It is there because a runner that spreads the same work over more
cores finishes sooner but costs the same, and on a busy machine CPU is what
runs out. A dev server's CPU is not measured, because it has not exited when
the document is served. The kernel counts CPU time in clock ticks, 10 ms on the
Linux runner, so a CPU figure under about 20 ms is a count of ticks rather
than a measurement.
- Installs use the npm registry. They are measured over the network, with
an empty home directory for the cold rows, so they depend on the registry.
Every row
A table per stage, fastest first. Against uf is the ratio of medians.
format check, cold — small
| Command | Version | Median | Min–max | CPU | Against uf | Between runs |
|---|
uf fmt --check | 0.0.0-alpha.46 | 13 ms | 11 ms–15 ms | 0.0 ms | | removes .uf, dist and node_modules/.vite before every run |
biome format . | 2.5.12 | 28 ms | 26 ms–30 ms | 80 ms | 2.1× uf | keeps no cache by default, so cold and warm are the same run |
prettier --check . | 3.9.8 | 344 ms | 338 ms–347 ms | 670 ms | 26.7× uf | keeps no cache by default, so cold and warm are the same run |
vp fmt --check | 1.0.0-rc.0 | 422 ms | 419 ms–451 ms | 1.19 s | 32.7× uf | keeps no cache by default, so cold and warm are the same run |
format check, warm — small
| Command | Version | Median | Min–max | CPU | Against uf | Between runs |
|---|
uf fmt --check | 0.0.0-alpha.46 | 12 ms | 11 ms–13 ms | 0.0 ms | | keeps what the run before it wrote |
biome format . | 2.5.12 | 26 ms | 26 ms–30 ms | 80 ms | 2.3× uf | keeps no cache by default, so cold and warm are the same run |
prettier --check . | 3.9.8 | 338 ms | 323 ms–368 ms | 670 ms | 29.1× uf | keeps no cache by default, so cold and warm are the same run |
vp fmt --check | 1.0.0-rc.0 | 433 ms | 416 ms–440 ms | 1.19 s | 37.3× uf | keeps no cache by default, so cold and warm are the same run |
lint, cold — small
| Command | Version | Median | Min–max | CPU | Against uf | Between runs |
|---|
uf lint | 0.0.0-alpha.46 | 28 ms | 27 ms–31 ms | 490 ms | | removes .uf, dist and node_modules/.vite before every run |
biome lint . | 2.5.12 | 32 ms | 31 ms–33 ms | 160 ms | 1.1× uf | keeps no cache by default, so cold and warm are the same run |
vp lint | 1.0.0-rc.0 | 272 ms | 260 ms–281 ms | 520 ms | 9.7× uf | keeps no cache by default, so cold and warm are the same run |
eslint . | 10.11.0 | 550 ms | 533 ms–571 ms | 1.12 s | 19.7× uf | keeps no cache by default, so cold and warm are the same run |
lint, warm — small
| Command | Version | Median | Min–max | CPU | Against uf | Between runs |
|---|
uf lint | 0.0.0-alpha.46 | 28 ms | 25 ms–30 ms | 480 ms | | keeps what the run before it wrote |
biome lint . | 2.5.12 | 32 ms | 31 ms–32 ms | 160 ms | 1.1× uf | keeps no cache by default, so cold and warm are the same run |
vp lint | 1.0.0-rc.0 | 268 ms | 253 ms–274 ms | 500 ms | 9.4× uf | keeps no cache by default, so cold and warm are the same run |
eslint . | 10.11.0 | 550 ms | 518 ms–569 ms | 1.14 s | 19.4× uf | keeps no cache by default, so cold and warm are the same run |
lint and type check, cold — small
| Command | Version | Median | Min–max | CPU | Against uf | Between runs |
|---|
flow full-check | 0.332.0 | 193 ms | 175 ms–200 ms | 1.49 s | 3.4× faster than uf | keeps no cache by default, so cold and warm are the same run |
tsc --noEmit | 6.0.3 | 600 ms | 568 ms–611 ms | 1.22 s | 1.1× faster than uf | keeps no cache by default, so cold and warm are the same run |
uf check | 0.0.0-alpha.46 | 659 ms | 621 ms–677 ms | 1.19 s | | removes .uf, dist and node_modules/.vite before every run |
lint and type check, warm — small
| Command | Version | Median | Min–max | CPU | Against uf | Between runs |
|---|
uf check | 0.0.0-alpha.46 | 106 ms | 102 ms–109 ms | 650 ms | | keeps what the run before it wrote |
flow full-check | 0.332.0 | 184 ms | 159 ms–202 ms | 1.42 s | 1.7× uf | keeps no cache by default, so cold and warm are the same run |
tsc --noEmit | 6.0.3 | 590 ms | 567 ms–632 ms | 1.21 s | 5.6× uf | keeps no cache by default, so cold and warm are the same run |
test suite, cold — small
| Command | Version | Median | Min–max | CPU | Against uf | Between runs |
|---|
bun test | 1.3.14 | 27 ms | 25 ms–28 ms | 30 ms | 10.6× faster than uf | keeps no cache by default, so cold and warm are the same run |
uf test | 0.0.0-alpha.46 | 284 ms | 281 ms–318 ms | 2.89 s | | removes .uf, dist and node_modules/.vite before every run |
vitest run | 5.0.1 | 333 ms | 324 ms–343 ms | 2.27 s | 1.2× uf | removes node_modules/.vite before every run |
vp test | 1.0.0-rc.0 | 462 ms | 455 ms–467 ms | 2.46 s | 1.6× uf | removes node_modules/.vite before every run |
test suite, warm — small
| Command | Version | Median | Min–max | CPU | Against uf | Between runs |
|---|
bun test | 1.3.14 | 26 ms | 25 ms–27 ms | 30 ms | 2.8× faster than uf | keeps no cache by default, so cold and warm are the same run |
uf test | 0.0.0-alpha.46 | 74 ms | 67 ms–77 ms | 90 ms | | keeps what the run before it wrote |
vitest run | 5.0.1 | 339 ms | 330 ms–343 ms | 2.27 s | 4.6× uf | keeps what the run before it wrote |
vp test | 1.0.0-rc.0 | 467 ms | 458 ms–482 ms | 2.48 s | 6.3× uf | keeps what the run before it wrote |
production build, cold — small
| Command | Version | Median | Min–max | CPU | Against uf | Between runs |
|---|
vp build | 1.0.0-rc.0 | 296 ms | 288 ms–307 ms | 750 ms | 8.6× faster than uf | removes dist and node_modules/.vite before every run |
uf build | 0.0.0-alpha.46 | 2.55 s | 2.50 s–2.56 s | 5.43 s | | removes .uf, dist and node_modules/.vite before every run |
next build | 16.3.6 | 3.05 s | 3.02 s–3.17 s | 15.56 s | 1.2× uf | removes .next before every run |
production build, warm — small
| Command | Version | Median | Min–max | CPU | Against uf | Between runs |
|---|
vp build | 1.0.0-rc.0 | 306 ms | 292 ms–322 ms | 780 ms | 7.9× faster than uf | keeps what the run before it wrote |
next build | 16.3.6 | 1.40 s | 1.37 s–1.40 s | 2.75 s | 1.7× faster than uf | keeps what the run before it wrote |
uf build | 0.0.0-alpha.46 | 2.42 s | 2.41 s–2.44 s | 5.43 s | | keeps what the run before it wrote |
dev server start to first document, cold — small
| Command | Version | Median | Min–max | CPU | Against uf | Between runs |
|---|
vp dev | 1.0.0-rc.0 | 241 ms | 229 ms–244 ms | — | 4.6× faster than uf | removes node_modules/.vite before every run |
next dev | 16.3.6 | 1.01 s | 1.00 s–1.04 s | — | 1.1× faster than uf | removes .next before every run |
uf dev | 0.0.0-alpha.46 | 1.10 s | 1.08 s–1.12 s | — | | removes .uf, dist and node_modules/.vite before every run |
dev server start to first document, warm — small
| Command | Version | Median | Min–max | CPU | Against uf | Between runs |
|---|
vp dev | 1.0.0-rc.0 | 233 ms | 230 ms–240 ms | — | 3.7× faster than uf | keeps what the run before it wrote |
uf dev | 0.0.0-alpha.46 | 875 ms | 839 ms–914 ms | — | | keeps what the run before it wrote |
next dev | 16.3.6 | 1.02 s | 1.01 s–1.03 s | — | 1.2× uf | keeps what the run before it wrote |
edit to HMR update message — small
| Command | Version | Median | Min–max | CPU | Against uf | Between runs |
|---|
vp dev; edit src/components/HotCounter.tsx | 1.0.0-rc.0 | 0.9 ms | 0.7 ms–1.1 ms | — | 2.7× faster than uf | one warm dev server; each edit rewrites one string literal |
uf dev; edit components/HotCounter.js | 0.0.0-alpha.46 | 2.4 ms | 1.9 ms–9.8 ms | — | | one warm dev server; each edit rewrites one string literal |
edit to updated module served — small
| Command | Version | Median | Min–max | CPU | Against uf | Between runs |
|---|
vp dev; edit src/components/HotCounter.tsx | 1.0.0-rc.0 | 2.5 ms | 2.0 ms–3.2 ms | — | 3.6× faster than uf | one warm dev server; each edit rewrites one string literal |
uf dev; edit components/HotCounter.js | 0.0.0-alpha.46 | 9.0 ms | 7.7 ms–17 ms | — | | one warm dev server; each edit rewrites one string literal |
many small test files, cold — suite
| Command | Version | Median | Min–max | CPU | Against uf | Between runs |
|---|
bun test | 1.3.14 | 57 ms | 56 ms–61 ms | 80 ms | 7.8× faster than uf | keeps no cache by default, so cold and warm are the same run |
uf test | 0.0.0-alpha.46 | 446 ms | 431 ms–487 ms | 5.95 s | | removes .uf, dist and node_modules/.vite before every run |
vitest run | 5.0.1 | 542 ms | 535 ms–551 ms | 6.32 s | 1.2× uf | removes node_modules/.vite before every run |
many small test files, warm — suite
| Command | Version | Median | Min–max | CPU | Against uf | Between runs |
|---|
bun test | 1.3.14 | 60 ms | 57 ms–61 ms | 80 ms | 1.7× faster than uf | keeps no cache by default, so cold and warm are the same run |
uf test | 0.0.0-alpha.46 | 103 ms | 97 ms–103 ms | 270 ms | | keeps what the run before it wrote |
vitest run | 5.0.1 | 543 ms | 539 ms–565 ms | 6.30 s | 5.3× uf | keeps what the run before it wrote |
install, cold — install
| Command | Version | Median | Min–max | CPU | Against uf | Between runs |
|---|
bun install | 1.3.14 | 478 ms | 400 ms–718 ms | 730 ms | 15.2× faster than uf | no lockfile, no node_modules, and an empty home, so an empty package-manager cache |
pnpm install | 10.34.5 | 1.34 s | 1.31 s–1.41 s | 3.12 s | 5.4× faster than uf | no lockfile, no node_modules, and an empty home, so an empty package-manager cache |
uf install | 0.0.0-alpha.46 | 7.28 s | 7.07 s–7.38 s | 7.89 s | | no lockfile, no node_modules, and an empty home, so an empty package-manager cache |
install, warm — install
| Command | Version | Median | Min–max | CPU | Against uf | Between runs |
|---|
bun install | 1.3.14 | 48 ms | 47 ms–51 ms | 40 ms | 18.9× faster than uf | the lockfile and package-manager cache the install before it left; no node_modules |
pnpm install | 10.34.5 | 411 ms | 396 ms–419 ms | 2.50 s | 2.2× faster than uf | the lockfile and package-manager cache the install before it left; no node_modules |
uf install | 0.0.0-alpha.46 | 906 ms | 879 ms–945 ms | 1.50 s | | the lockfile and package-manager cache the install before it left; no node_modules |
Asked for and not measured
next hmr: next dev pushes its updates over a socket protocol of its own, and the HMR stage speaks Vite's
The regression gate
The Bench workflow also runs the uf half on its own every night, and fails
when a uf stage is more than 20% and 25 ms slower than the same row of
tools/bench/toolchain/baseline.json. Four rules keep the noise of a shared
runner from failing it:
- Every row is the median of seven timed runs, after one run that is not
counted. A busy neighbour only ever slows a run down, and a median ignores
up to three slow runs out of seven.
- The line is a percentage and a number of milliseconds. The format and HMR
rows take a few tens of milliseconds, and 20% of that is less than the
variation in starting a process.
- A stage over the line is measured again in a new process, and the faster of
the two medians is compared. A busy neighbour rarely slows both runs; a
slower uf does.
- The baseline comes from the same kind of runner the check runs on, never
from a laptop. The check warns when the CPU model differs.
Installs are measured but not gated, because the registry's speed on a given
night is not something a change to uf can affect. tools/bench/toolchain/regress.js
explains each rule.
To refresh the baseline after a change that makes uf faster or intentionally
slower, or after the runner changes: run the Bench workflow by hand, download
its bench-comparison artifact, copy its results.json over
tools/bench/toolchain/baseline.json, run uf fmt (it reformats the JSON and
changes no number), and commit it. This page is rebuilt from the new file.
What is not measured
- A browser. The HMR rows stop when the edited module has been served. The
browser evaluating it and React re-rendering are not timed.
- The
large preset in CI. --preset large is ten times the routes and
modules and is run by hand. The nightly job runs small.
- Windows. The harness times commands in process groups and reads CPU time
through a POSIX shell.
next dev HMR and vp dev server rendering, for the reasons above.
Where to go next
What uf does not do is next: what uf refuses on purpose, and
every gap with its issue number.
Next: What uf does not do →
Edit this pagedocs/app/guide/benchmarks/$page.mdx