Where a task run spends its time
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) |
<small>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</small>
A cold run is the tasks
!diagram
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][turbo-restore]).
vx leaves a task's outputs alone if each file still matches its record
(size, mode, modification time, inode, change time).
[turbo-restore]: github.com/vercel/turborepo/blob/v2.11.8/crates/t…
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 |
<small>CPU profile of the main process, all 9,603 tasks hit, 173 env vars · source nx@23.3.0 task-env.js, cache.js</small>
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.
---
Originally published on the vx blog. vx is open-source (MIT) task orchestration with a build cache: vznjs.github.io · GitHub.
<sub>_Written with AI assistance._</sub>
1 reaction · 5 comments
An input changes mid-run: what four task tools do
You save a file while a build runs. The output may come from bytes the
cache key does not name. Here is what each tool does after the command
exits, read from its source.
| After the command exits | Turborepo 2.11.7 | Nx 23.3.0 | Vite Task 1.1.0 | vx |
| ---------------------------- | ---------------- | ----------------- | ------------------------- | -------------------------- |
| Inputs looked at again | no | no (batch: rehash) | files the task opened | every declared input |
| Writes it notices | none | none | the task's own | any process's |
| What it does on a change | saves | saves | skips the save | skips the save, names file |
| Can be relaxed per task | n/a | n/a | n/a | inputStability |
Turborepo 2.11.7
It takes the hash before the run and saves the outputs under it.
Inputs are not checked again
(save_outputs).
Nx 23.3.0
It hashes in processTask and saves with cache.put(task.hash, …). In
batch mode, a task that reads its dependencies' outputs is hashed again
(task-orchestrator.js,
cache.js).
Vite Task (vite-plus 1.1.0)
It traces the files a task opens. If the task wrote a file it also read,
it skips the save (InputModified). Writes by other processes are not
traced
(cache_update.rs).
vx
$ vx run packages/api#build
┌─ packages/api#build > $ echo "export const r = $RANDOM" > src/routes.gen.ts && mkdir -p out && cp src/index.ts out/
[vx] packages/api#build: `packages/api/src/routes.gen.ts` changed after its key was taken — the result stands, but is not saved under a key that no longer describes it; if the task writes it, declare it in cache.outputs
└─ packages/api#build ── (6ms) success
cache 1 not saved
<small>One-project workspace · vx c1ad075 · 2026-10-23</small>
vx checks every declared input after the run, whoever wrote it. A
changed input keeps the result and saves nothing, and tasks downstream
skip their save too. cache.inputStability relaxes it per task
(how).
Each one is a trade
vx pays one lstat per input on a run that saves. Turborepo and Nx skip
that work. Vite Task checks what its tracer already saw. Which fits
depends on how often your inputs move during a run. More at
vznjs.github.io.
---
Originally published on the vx blog. vx is open-source (MIT) task orchestration with a build cache: vznjs.github.io · GitHub.
<sub>_Written with AI assistance._</sub>
Mentions
One monorepo, too many repeated builds? ⚙️ Turborepo organizes tasks and caches results across JS and TS projects. Keep multi-app workflows manageable. 📦 vercel/turborepo #Monorepo #浇朋友 #蓝朋友 #互关 #fo
前端 Monorepo 一构建,所有包都跟着重跑?⚙️ Turborepo 管理项目任务和缓存,减少不必要的重复执行。适合多应用仓库。📦 vercel/turborepo #Monorepo #浇朋友 #蓝朋友 #互关 #fo
Where a task run spends its time 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) | <small>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</small> A cold run is the tasks !diagram 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][turbo-restore]). vx leaves a task's outputs alone if each file still matches its record (size, mode, modification time, inode, change time). [turbo-restore]: github.com/vercel/turborepo/blob/v2.11.8/crates/t… 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 | <small>CPU profile of the main process, all 9,603 tasks hit, 173 env vars · source nx@23.3.0 task-env.js, cache.js</small> 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. --- Originally published on the vx blog. vx is open-source (MIT) task orchestration with a build cache: vznjs.github.io · GitHub. <sub>_Written with AI assistance._</sub>
An input changes mid-run: what four task tools do You save a file while a build runs. The output may come from bytes the cache key does not name. Here is what each tool does after the command exits, read from its source. | After the command exits | Turborepo 2.11.7 | Nx 23.3.0 | Vite Task 1.1.0 | vx | | ---------------------------- | ---------------- | ----------------- | ------------------------- | -------------------------- | | Inputs looked at again | no | no (batch: rehash) | files the task opened | every declared input | | Writes it notices | none | none | the task's own | any process's | | What it does on a change | saves | saves | skips the save | skips the save, names file | | Can be relaxed per task | n/a | n/a | n/a | inputStability | Turborepo 2.11.7 It takes the hash before the run and saves the outputs under it. Inputs are not checked again (save_outputs). Nx 23.3.0 It hashes in processTask and saves with cache.put(task.hash, …). In batch mode, a task that reads its dependencies' outputs is hashed again (task-orchestrator.js, cache.js). Vite Task (vite-plus 1.1.0) It traces the files a task opens. If the task wrote a file it also read, it skips the save (InputModified). Writes by other processes are not traced (cache_update.rs). vx $ vx run packages/api#build ┌─ packages/api#build > $ echo "export const r = $RANDOM" > src/routes.gen.ts && mkdir -p out && cp src/index.ts out/ [vx] packages/api#build: `packages/api/src/routes.gen.ts` changed after its key was taken — the result stands, but is not saved under a key that no longer describes it; if the task writes it, declare it in cache.outputs └─ packages/api#build ── (6ms) success cache 1 not saved <small>One-project workspace · vx c1ad075 · 2026-10-23</small> vx checks every declared input after the run, whoever wrote it. A changed input keeps the result and saves nothing, and tasks downstream skip their save too. cache.inputStability relaxes it per task (how). Each one is a trade vx pays one lstat per input on a run that saves. Turborepo and Nx skip that work. Vite Task checks what its tracer already saw. Which fits depends on how often your inputs move during a run. More at vznjs.github.io. --- Originally published on the vx blog. vx is open-source (MIT) task orchestration with a build cache: vznjs.github.io · GitHub. <sub>_Written with AI assistance._</sub>