Benchmarks
Task runners spend most of their wall-clock on the work they launch. To compare the runners themselves, these scenarios run tasks that do almost nothing and measure what's left: process startup, config parsing, and the cost of reaching the child commands.
The same task definitions are generated for every tool (tsr, npm, bun, nub, deno, just, go-task, make, and mise) — see benches/ — and timed with hyperfine. The numbers below are loaded directly from the benchmark's JSON export.
The last group is different: a real multi-package workspace, measuring the topological fan-out (^task) that makes tsr a monorepo runner. It compares only tsr, pnpm, and bun — see why.
startup — a single task
Spawns true. This is pure per-invocation overhead, and where the compiled runners separate from the interpreter-based ones.
Mean wall-clock, lower is faster. × is relative to the fastest runner (tsr, 0.8 ms).
shell one-liner — $VAR + &&
A task defined as echo $HOME && echo done. This is where tsr's mini-shell earns its place: it expands $VAR and sequences with &&/||/; in-process, without launching a shell. tsr spawns each command directly, so on a one-liner that a real shell would run with builtins it can be a touch slower — the win comes when the commands are real programs. (Pipes and redirects aren't in the mini-shell; those use a delegate to sh -c as an escape hatch, not measured here.)
Mean wall-clock, lower is faster. × is relative to the fastest runner (tsr, 0.8 ms).
coreutils builtins — mkdir + touch + cp + mv + rm
File operations executed in a sequence (mkdir, touch, cp, mv, rm). tsr runs these in-process via native Rust coreutils without spawning shell sub-processes or external binaries, outperforming conventional shell execution.
Mean wall-clock, lower is faster. × is relative to the fastest runner (tsr, 1.0 ms).
glob expansion — echo src/*.rs
A task matching files via * globbing. tsr parses and resolves globs in-process against the filesystem relative to the task's dir.
Mean wall-clock, lower is faster. × is relative to the fastest runner (tsr, 0.9 ms).
local binary — resolving node_modules/.bin
The real "npm run replacement" case: a task that calls a project-local tool like vite or eslint. These are Node programs, so every runner ultimately spawns Node; what differs is the runner's own startup on top. Only the runners that resolve node_modules/.bin can do this — tsr, npm, bun, nub, and deno — so this is a five-way comparison (just/make/go-task/mise don't resolve project-local binaries, so a bare recipe wouldn't find the tool at all). tsr prepends node_modules/.bin to PATH like npm/bun/nub/deno, then spawns the tool directly — skipping npm's extra Node startup. This is the one scenario where tsr is not first: bun reaches the tool faster still, and the three of bun/tsr/deno land within ~9 ms of each other once Node's own startup dominates.
Mean wall-clock, lower is faster. × is relative to the fastest runner (bun, 20.7 ms).
in-task steps — one launch, five commands
One task that runs five commands in sequence. Every runner launches once and then spawns the child commands, so even npm/bun stay cheap here — the startup tax is paid a single time.
Mean wall-clock, lower is faster. × is relative to the fastest runner (tsr, 0.8 ms).
dependency graph — five tasks
One task depending on five trivial tasks. tsr, just, go-task, make, and mise resolve the whole graph in a single launch. npm, bun, nub, and deno have no dependency graph, so — as their users do — the five tasks are chained with &&, paying the runner's startup five times over.
Mean wall-clock, lower is faster. × is relative to the fastest runner (tsr, 0.9 ms).
dependency graph — ten tasks
The same, scaled to ten dependencies. Overhead that is paid per-invocation grows linearly: the graph-native runners barely move, while every chained runner costs roughly double what it did on the five-task graph. npm reaches ~852 ms for ten no-op tasks — about 860× tsr, which stays near 1 ms.
Mean wall-clock, lower is faster. × is relative to the fastest runner (tsr, 1.0 ms).
topological fan-out — a real multi-package workspace
Everything above uses a single package. These scenarios use a workspace of N packages in a dependency chain, each with a no-op build, and ask each runner to build them all in dependency order — tsr with deps = ["^build"], pnpm with -r, bun with --filter.
Only those three take part, because only those three actually order packages by dependency. The chain is generated so the correct order is the reverse of alphabetical, which is how that was checked — npm --workspaces walks packages in name order and gets it wrong, so it isn't doing this job and is left out rather than shown losing a race it never entered.
Mean wall-clock, lower is faster. × is relative to the fastest runner (tsr, 1.4 ms).
Mean wall-clock, lower is faster. × is relative to the fastest runner (tsr, 3.7 ms).
Mean wall-clock, lower is faster. × is relative to the fastest runner (tsr, 11.9 ms).
tsr's marginal cost is ~56–62 µs per package, and it falls slightly as the workspace grows (62 µs at 10 packages → 56 µs at 200). Reading every package manifest to build the dependency graph therefore stays off the critical path: what dominates is spawning each child, not resolving the graph.
The two comparisons move in opposite directions. Against pnpm, tsr's lead narrows with scale (118× → 80× → 53×) as pnpm amortises its large fixed startup. Against bun it widens (7.6× → 12.3× → 14.9×), because bun's per-package overhead is the bigger term.
They exist to do content-hash caching — which tsr deliberately delegates to them rather than reimplementing (see the overview). A no-op benchmark would time them either cold, where tsr "wins" only by having no cache to populate, or warm, where they "win" only by skipping the work. Neither number would tell you anything true.
Takeaway
For a single task, tsr runs a metacharacter-free run string by spawning the child directly (execvp-style) — no language runtime, no wrapping shell — so it sits with the native runners (make, just) and well ahead of npm run. The gap compounds across a dependency graph: because tsr resolves the entire graph in one process, its cost stays flat while a chained npm/bun/nub/deno pays its startup once per task. That is the case tsr is built for.
This measures runner overhead only. Content-hash and incremental caching are deliberately delegated to Turbo/Nx (see the overview) — out of scope here.
Measured on one reference machine (Linux x86-64). Regenerate with benches/run.sh, then node website/tools/sync-bench.mjs to refresh this page.