We traced one 8,002-task run through vx, Turborepo and Nx. A cold run is
almost all task time. A warm run is how little the tool does.
| Run | vx | Turborepo 2.11.8 |
|---|---|---|
| Cold | 120.2 s | 127.3 s |
| Warm | 892 ms | 2,496 ms first, 909 ms second |
| One source file changed | 100.1 s (6,607 run) | 105.8 s (6,607 run) |
| That edit reverted | 3.6 s (1,321 tasks restored, the rest left in place) | 5.2 s (all 8,002 replayed) |
Synthetic bench, 1,601 projects, 9,603 graph nodes · 4 cores, concurrency 10, CI=1, local cache only, Linux · vx 9388bad8f (VX_DEBUG_TIMING=1), Turborepo 2.11.8 (--profile), Nx 23.3.0 (nx:run-commands, daemon off, .env off) · 2026-10-13/14 · method
A cold run is the tasks
vx adds about 0.4 ms per task for spawn, env and output capture. Before
the first task it spends about 640 ms on projects, graph and git.
Turborepo's precompute is about 0.7 s.
Some extra work is a safety check
| Check | Cost |
|---|---|
| Graph rules (output overlap, missing edge) | none at run time, vx lock checks once |
| Input recheck after a miss | about 0.2 ms per miss |
| Clear outputs before a miss | about 0.15 ms per miss |
| Save before dependents start | at most about 0.14 s of 120 s |
| Blob-size check, cold cache | about 330 ms, once per git index |
Remote uploads run in the background. Only the end of the run waits for
them. We measured each check and kept it.
A warm run decides not to work
| Step | vx | Turborepo 2.11.8 |
|---|---|---|
| Projects and graph | discover, then graph with rules | precompute |
| File hashes | git's own index (63 ms) | git index, then per-package file hashes |
| Keys | one per task | one per task |
| On a hit | checks each task's outputs on disk; restores a task's outputs only if one differs | restores every output and log |
Without its daemon, Turborepo rewrites every output on a hit
(restore_outputs).
vx leaves a task's outputs alone if each file still matches its record
(size, mode, modification time, inode, change time).
Nx 23.3.0, warm: 21 to 24 s
| Self time | Where |
|---|---|
| 6.10 s |
hashTasksUpfront: hands each task's environment to the native hasher |
| 5.55 s | garbage collection |
| 3.03 s |
getTaskSpecificEnv: copies process.env for each task |
| 2.89 s |
copyFilesFromCache: writes every cached output back |
| 1.56 s |
plan, the native planner |
CPU profile of the main process, all 9,603 tasks hit, 173 env vars · source nx@23.3.0 task-env.js, cache.js
getTaskSpecificEnv copies process.env for every task, so it grows
with your environment.
What a change reruns
| Change | vx | Turborepo 2.11.8 |
|---|---|---|
| Unrelated env var set | all hit | all hit |
Root package.json edited |
all hit | all 8,002 miss |
One project's package.json
|
6,607 miss | 6,607 miss |
bun.lock added |
all miss | all miss |
vx sees root dependencies through the lockfile. A lockfile plugin such as
bun() narrows the last row to the projects whose dependencies moved.
What we checked in vx and dropped
The output check on hits looked like 655 ms. Alone it is 10.8 ms over
1,600 hits: the trace summed ten workers.
A tool that rewrites outputs with the same bytes makes vx restore them,
but a content hash on every save would cost more. Neither became a
patch.
The bench and its scripts are in the repository. More at
vznjs.github.io/vx.
Originally published on the vx blog. vx is open-source (MIT) task orchestration with a build cache: vznjs.github.io/vx · GitHub.
Written with AI assistance.
Top comments (5)
The root package.json invalidation row is the most honest data point here. Most benchmarks only show cold and warm runs, but the cache correctness behavior after a root file edit is what teams actually fight with. I would be curious how the numbers move on a smaller workspace, since 1,600 projects probably amplifies the graph-rule costs.
Thanks, that row is the one we care about most too. A stale hit after a root file edit is the worst thing a cache can do, because it replays wrong output under a green run, so cold and warm numbers alone don't tell you much.
On workspace size: vx is optimized for large monorepos, where per-task costs add up, but it does just as well on small ones. Since this post, a run doesn't check graph rules at all.
vx lockchecks them once and writes the result, and every run just reads that lock, so the cost you're asking about is zero in a run at any size.We've since timed vx against each repo's own runner on seven real projects, small ones included (t3, tRPC, react-email): vznjs.github.io/vx/blog/vx-on-real...
Good to hear the lock check cost is amortized across runs. Did the seven-repo timing surface any small project where the per-task guard overhead outweighed the cache benefit, or was vx ahead everywhere?
Not everywhere, and the post keeps the slower rows in. On the smallest repo, create-t3-turbo, vx was behind on one row: a one-file edit, 4.89 s against Turborepo's 4.69 s. That wasn't per-task overhead. By default vx deletes a task's declared outputs before a cache miss runs, tsc's tsbuildinfo included, so tsc rebuilt from scratch. With
cache.outputs.clean: falsethe same edit took 3.83 s.The other slower rows are on larger repos and come from restore: on typescript-eslint, restore took 5.32 s against Nx's 4.45 s, and on medusa a clean clone took 3.54 s against Turborepo's 2.59 s. Both are bound by how fast vx writes the outputs back, which is what we're working on now.
Thanks for the detailed breakdown. It is reassuring that the gap comes from output write speed rather than per-task guard overhead, since that is an engineering problem with a clear ceiling. Would it make sense to surface the cache.outputs.clean flag more prominently for smaller repos, where a full tsc rebuild hurts the most?