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, 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.
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 (make, 1.6 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 (make, 2.0 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, and deno — so this is a four-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/deno, then spawns the tool directly — skipping npm's extra Node startup.
Mean wall-clock, lower is faster. × is relative to the fastest runner (bun, 22.4 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 (bun, 3.0 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 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 (make, 3.7 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 the chained npm/bun costs roughly double from the five-task graph.
Mean wall-clock, lower is faster. × is relative to the fastest runner (make, 5.9 ms).
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 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.