When more tasks are ready than workers, a runner has to pick. Five rules for picking, the graphs where each one wins and loses, measured with vx, and why recorded timings beat every rule that has to guess.
Performance
Performance
A build has more ready tasks than workers most of the time. Something
has to decide which one starts next. That choice can change the wall
time of a run by a third, with the same tasks, the same machine and the
same worker count.
Without timings, every rule is a guess. This post walks through five
rules, shows a graph where each one wins and a graph where it loses,
and measures them all with vx.
With no timings, vx assumes the task that blocks the most work matters
most. It counts every task downstream of each ready task and starts the
highest count first.
If every task took the same time, the task with the most work behind it
usually sits at the head of the longest chain too. So with equal tasks
the rule lands close to the textbook rule, longest chain first. Real
tasks are not equal, and that is where any rule without timings can be
wrong.
The best order starts the 60-second task at once and runs both chains
on the other worker: 60 seconds in all. Counting dependents puts both
parents ahead of the long task, because each has work behind it and the
long task has none:
best (60 s) worker 1 long ████████████████████████████████
vx default (90 s) worker 1 parent ███████████████ long ████████████████████████████████
worker 2 parent ███████████████ child child
Measured with vx, two workers:
Wall time
Ready order60.2 s
Most direct dependents60.2 sa tie, settled by ready order
Most transitive dependents (vx’s default)90.0 s
Critical path by step count89.9 s
Recorded time, run 1 (no history yet)90.0 s
Recorded time, runs 2 and 360.2 s, 60.2 s
The table
Rule
Wall time
Ready order
60.2 s
Most direct dependents
60.2 s (a tie, settled by ready order)
Most transitive dependents (vx’s default)
90.0 s
Critical path by step count
89.9 s
Recorded time, run 1 (no history yet)
90.0 s
Recorded time, runs 2 and 3
60.2 s, 60.2 s
vx’s default is 49% slower here. In a real repo this shape is a slow
task that runs straight from source with nothing after it: a typecheck,
a lint, or unit tests on a big package that need no build. It is ready
from the first second, and the default can start it late. App builds
and end-to-end tests do not fit this shape, since they wait on
libraries or a deploy.
Ready order won here because the long task was first in line. It has
no way to know that; it only started the task that was ready first.
Ten workers. 100 independent tasks and one chain of two, A then B.
Every task takes 2 seconds.
flowchart LR
T[100 independent tasks · 2 s each]
A[A · 2 s] --> B[B · 2 s]
Counting dependents starts A first, so B runs beside the last wave of
independent tasks. Ready order can leave A behind the other 100. Then B
starts only after the last wave, and the run gets one task longer.
Wall time
Ready order24.3 s
Most direct dependents24.3 sa tie, settled by ready order
Most transitive dependents (vx’s default)22.3 s
Critical path by step count22.4 s
Recorded time, runs 1 to 322.3 s, 22.2 s, 22.3 s
The table
Rule
Wall time
Ready order
24.3 s
Most direct dependents
24.3 s (a tie, settled by ready order)
Most transitive dependents (vx’s default)
22.3 s
Critical path by step count
22.4 s
Recorded time, runs 1 to 3
22.3 s, 22.2 s, 22.3 s
Here ready order is 9% slower. Each loss in these two cases costs about
one task’s length.
The headline benchmark’s graph: 1,090 packages and 3,270 tasks in 100
dependency layers, every build and test taking 1 second, on 10 workers.
With every task the same length, the best possible run is 218 seconds:
2,180 seconds of work spread over 10 workers.
Wall time
Ready order301.7 s
Most direct dependents220.5 s
Most transitive dependents (vx’s default)220.4 s
Critical path by step count220.6 s
Recorded time, runs 1 to 3220.6 s, 220.5 s, 220.5 s
The table
Rule
Wall time
Ready order
301.7 s
Most direct dependents
220.5 s
Most transitive dependents (vx’s default)
220.4 s
Critical path by step count
220.6 s
Recorded time, runs 1 to 3
220.6 s, 220.5 s, 220.5 s
Every rule that looks at the graph lands within 3 seconds of the best
possible run. Ready order takes 37% longer. Each layer’s builds wait on
every build in the layer below, and under ready order a layer’s last
build finished about 3 seconds after the layer before it; under vx’s
default it was 2.2 seconds, the pace the work itself allows (22 seconds
of tasks per layer over 10 workers). Order helps on this graph shape, and with equal tasks the
shape is all there is to know, so history has nothing to add.
Case 1 and Case 2 are mirror images. Any rule that ranks by graph shape
alone loses one of them, because shape cannot tell a 60-second task
from a 2-second one. The loss is bounded by about the length of the
task that started late, but it is real, and vx’s default does lose
Case 1.
What closes the gap is a duration. The history plugin ranks each ready
task by its own recorded time plus the longest recorded chain behind it.
Its first run has no history and keeps vx’s default order. From the
second run on, it found the best order in every case above.
That is why vx does not ship more guessing rules. Each one trades one
losing graph for another, and history beats all of them after one run.
A rule could guess durations before any history exists: by task kind
(test slower than lint) or by input count. We looked at it and
decided against it. It helps only the very first run on a machine, and
in practice that is mostly a benchmark. If you know a task is slow, the
plugin’s assume option names its duration for runs with no history:
It learns from the local run history, the last 20 runs by default.
Ordering is a plugin seam, schedule, so history can live elsewhere
too: a plugin can read timings from a store shared by your CI machines
and rank with the same criticalPathPriorities function the history
plugin exports.
Synthetic workspaces, not real compilation: every task is a sleep.
Run 2026-10-09 on linux x64, 4 cores, Bun 1.4.2, vx from source at
main at a85223a, one run per row, cold (--force, so every task runs).
Each rule other than vx’s default and the history plugin is a small
schedule plugin written for this post. Case 1 and Case 2 run through
one group task that depends on every leaf. So in Case 1 the long task
and each parent have one direct dependent, and in Case 2 every
independent task and A have one. That is why “most direct dependents”
ties in both and falls back to ready order.
Each history arm starts from an empty history in its own copy of the
workspace.
Keep reading
Performance
How many tasks at once? Count the cores you are given
Inside a container, the CPU count a program sees is the host’s. A task runner that trusts it starts more work than the kernel will run. Here is how vx picks its worker count.
With @vzn/vx-schedule-history, vx starts the longest chain first and packs tasks by the memory they really used. vx history shows what it learned, task by task.
A broken build skips what depends on it and nothing else. Every skipped task names the failure that blocked it. Pick fail-fast or run-everything with one flag, and set how many tasks run at once as a count or a share of the CPUs.
Turborepo, Nx and other product names are trademarks of their owners. vx is not affiliated with or endorsed by them.