Compared to Turborepo, Nx, vite-task
A side-by-side reference for what each of the four tools does, plus an
explicit list of gaps @vzn/vx has against the other three. What vx
shares with them is in patterns.md.
This is a living document; every claim cites a source file or reference
page in the upstream repo so future revisions can be diffed against
reality. Last verified 2026-09-03 against turbo@2.10.12, nx@23.2.0
and voidzero-dev/vite-task main (now the engine behind Vite+‘s
vp run). Five Turbo and Nx rows (the lockfile, non-JS projects, the
daemon, the plugin surface, Nx’s language plugins) were corrected on
2026-09-24 against the tools’ docs sources, for the choosing page (item
689).
Positioning in one paragraph each
Section titled “Positioning in one paragraph each”- Turborepo — production-grade. Per-package
turbo.json, remote cache, OTLP observability, watch, prune, query, boundaries, a TUI. Maximally featureful; many features are flagged “experimental”, and it has deprecated its own daemon forturbo run(2.8.11) and--parallel(by 2.9.18); the flag surface is the largest of the four. Reference repo:vercel/turborepo. - Nx — production-grade and pluggable. Per-package
project.json, plugins that infer tasks and add generators and executors (first-party Gradle, Maven and .NET; community Rust, Go and Python),affectedsemantics, Terminal UI, named inputs / target defaults, distributed task execution via Nx Cloud agents. Heaviest schema. Reference repo:nrwl/nx. - vite-task — Rust-fast, smallest schema, novel filesystem-spy
auto-input inference (default
{auto: true}). Pre/post script lifecycle frompackage.jsonscripts. SQLite + tar.zst local cache with make-style validate-at-lookup (traced reads re-fingerprinted on every hit — gives early cutoff, but the key isn’t derivable before execution, which is why it has no remote cache). Reference repo:voidzero-dev/vite-task. @vzn/vx— one thing, built to be built on. TypeScript-native config, opt-in caching, Turbo-shape cache key with one extension (resolved-config hash captures TS imports); package files key a task only through a plugin (@vzn/vx-lockfile). One self-contained binary per platform (Linux and macOS, x64 and arm64; Windows under WSL) — no Node or Bun to run it. Smallest CLI surface; deliberately no daemon, no cloud, no account and no JS-function tasks. The core is a Vite-style pipeline: plugins hook each stage (config→discover→project→graph→key→fingerprint→measures→schedule→admit), supply the executor (WHERE a command runs, never what it is), the cache layers and the telemetry sinks, and add verbs — core applies none of them by default and names none. Strict output ownership. What Nx would be if it were not a product.
Every row a Turbo or Nx user would look for, spelled in vx and pinned by
a test that runs the real CLI, is parity.md; this page
is the wider matrix and the reasoning.
Quick CLI flag map
Section titled “Quick CLI flag map”turbo run / nx run-many / vp run / vx run:
| Capability | Turbo | Nx | vite-task | vx |
|---|---|---|---|---|
| pnpm-style filter DSL | --filter |
--projects, --exclude |
--filter |
--filter |
| select by project tag | — | -p tag:<name> |
— | --filter tag:<name> (config tags; -p tag: aliases it) |
| recursive (every project) | implicit | implicit (no -p); --all deprecated |
-r |
--all |
| transitive deps | pkg... |
--with-deps (legacy) |
-t |
pkg... (via DSL) |
pkg#task addressing |
yes | nx run pkg:target |
yes | yes |
| concurrency cap | --concurrency |
--parallel |
--concurrency-limit |
--concurrency |
| serialize / drop dep order | --parallel (deprecated) |
(always topo) | --parallel |
--concurrency 1 to serialize; no --parallel (see note) |
| skip dependsOn | --only |
--excludeTaskDependencies |
--ignore-depends-on |
--exclude-dependencies[=<names>] |
pkg#task + bare task |
cross-product: web#lint build also runs web#build |
— | — | ≠ bare names take the filter scope only; an anchored task never widens it |
| one-at-a-time task | — | parallelism: false |
— | a @vzn/vx-history cpus reservation above the worker count runs alone; or --concurrency 1 |
| forward args | -- |
--args="..." |
trailing args | -- |
| skip cache reads+writes | --cache-policy=local:,remote: (--force skips reads, --no-cache writes) |
--skipNxCache, --skipRemoteCache |
--no-cache |
--no-cache (all off) / --force (reads off, writes on) / --cache-policy=<spec> per-layer |
| dry-run (print plan) | --dry, --dry=json |
--graph renders |
— | --dry, --dry=json |
| affected (git-relative) | --affected |
full affected subcommand |
— | --affected[=<base>] + [<since>] filter form |
| graph render | --graph file.{dot,html} |
--graph |
— | — (--dry=json lists each task’s deps) |
| continue past failure | --continue=never|dependencies-successful|always |
--nxBail |
— | --continue[=never|deps-ok|always] (deps-ok default) |
| per-run JSON summary | --summarize, --json |
— | --last-details replay |
vx last --format json |
| output log mode | --output-logs=full|hash-only|new-only|errors-only|none |
--outputStyle=tui|dynamic|static|stream|… |
--log=interleaved/labeled/grouped |
--output-logs full|hash-only|errors-only|none (+ flow-derived default) |
| profile / Chrome trace | --profile |
(via Nx Cloud) | — | --profile[=<path>] |
| daemon on/off | (deprecated in 2.8.11; ignored) | (Nx daemon, always on) | — | (no daemon) |
| retries / timeouts | — | — | — | --retries <n>, --timeout <duration> (also per task in config) |
| remote placement / outputs | — (remote cache only) | Nx Cloud agents | — | --cache-download=all|toplevel|none, exec.remote (executor plugin, e.g. @vzn/vx-reapi) |
| run report | --summarize |
— | — | vx last replays any recorded run; @vzn/vx-ci writes the job summary |
| watch mode | turbo watch |
nx watch |
— | vx watch <task> |
| version / help | --version / --help |
--version / --help |
--version / --help |
--version, --help / -h |
Sources: Turbo /docs/reference/run (turborepo.dev, 2.10.12); Nx
/reference/core-api/nx/documents/run-many (nx.dev, 23.2.0);
vite-task /crates/vite_task/src/cli/mod.rs; vx src/cli/run.ts.
Why no
--parallel? Turbo’s--parallelexists because users often over-declaredependsOnand want an escape hatch. In vx,dependsOnis opt-in and explicit — if you wrotedependsOn: ['^build']you meant it. The legitimate “I want to fan out without waiting” cases are already covered by (a) not declaringdependsOnin the first place, and (b)--exclude-dependencies, which keeps dependencies from running, entirely or selectively.
Config schema comparison
Section titled “Config schema comparison”| Schema feature | Turbo | Nx | vite-task | vx |
|---|---|---|---|---|
| Config language | JSON (turbo.json) |
JSON (project.json, nx.json) |
Vite config (run key) |
TypeScript (vx.config.ts) |
| Per-package config | yes | yes | yes | yes |
| Workspace-level config | turbo.json at root + extends |
nx.json |
root vite.config.* |
.vx/workspace.ts (run, cache, plugins) |
Per-task dependsOn: same project |
bare name lint |
bare name | bare name | 'lint' |
Per-task dependsOn: workspace deps |
^lint |
^lint ({projects:"dependencies"} deprecated) |
pkg#task |
'^lint' |
Per-task dependsOn: arbitrary other package’s task |
pkg#task |
{projects:["pkg"],target:"task"} |
pkg#task |
'pkg#task' |
Wildcards in dependsOn |
— | v19.5+: build-*, ^build-* |
— | 'build.*', '^build.*' (task-name patterns; bare * stays filter-only) |
| Group / umbrella tasks | tasks with dependsOn only |
(achieved via target groups) | (none) | yes — tasks with no exec |
| Input declarations | inputs: [...] + $TURBO_DEFAULT$ etc. |
inputs: [...] w/ rich types |
input: glob or {auto:true} |
inputs.files: string[] |
| Auto-input inference | — | — | yes (fspy, see §3) |
— out of scope (see §3) |
| Root-anchored inputs/outputs | $TURBO_ROOT$/… |
{workspaceRoot}/… |
(none) | inputs/outputs.workspaceFiles |
| Runtime-command inputs (tool versions, probes) | — (vercel/turborepo#4124) | runtime input |
(none) | inputs.runtime / workspaceRuntime |
| Frozen / locked resolved configs | — | — | — | vx lock (every run reads it) |
| Migration generator from other runners | — | — | — | bunx @vzn/vx-migrate (Turbo, Nx → vx.config.ts) |
| Named / reusable input sets | (none) | namedInputs at workspace + project level |
(none) | rejected by design — TS arrays/imports compose |
| Per-task env inputs | env: ["NODE_ENV"] |
inputs: [{env: "NODE_ENV"}] |
env: [...] + untrackedEnv |
inputs.env: string[] |
| Pass-through env | passThroughEnv |
(always pass through) | untrackedEnv (passed, no hash) |
exec.env.passThrough |
| Define / literal env | (no; rely on globalEnv) | (via executor options) | (no; in script) | exec.env.define |
| Workspace-level env inputs | globalEnv, globalPassThroughEnv |
workspace namedInputs + inputs |
(no) | rejected by design — TS presets compose |
| Output declarations | outputs: [...] |
outputs: [...] |
output: glob or {pattern,base} |
outputs.files: string[] |
| Output cleaning before exec / restore | (no — additive) | (no — additive) | (via materialized artifacts) | yes — strict |
Implicit-dependency hash (project package.json) |
yes — package.json is a default input |
externalDependencies |
(via lockfile) | via @vzn/vx-lockfile — an input of every cached task |
| Lockfile-aware invalidation (only the projects a dependency change reaches) | per package: the lockfile changes that affect it | yes — pruned lockfile per project, in the daemon’s graph | (whole lockfile) | @vzn/vx-lockfile — per-project closure digest, recorded by vx lock and held to the lockfile’s hash; --affected follows it |
| Resolved-config hash (captures TS imports) | — | — | — | yes — node.config JSON hashed |
| Persistent / long-running tasks (dev servers) | persistent, interruptible, interactive, with |
continuous |
(handled outside graph) | exec.persistent.readyWhen, exec.interactive |
| Configurations (named option sets) | — | configurations + -c |
— | — gap |
Per-target metadata (description) |
description |
metadata.description |
— | description: string |
| Target defaults / inheritance | extends, task extends |
targetDefaults (priority-resolved) |
(no) | rejected by design — presets are TS imports |
| Pre/post script lifecycle | yes — the package manager runs them | (executor-defined) | enablePrePostScripts: true |
vx init folds them into the command |
| Boundaries / package-tag visibility | boundaries.tags |
@nx/enforce-module-boundaries |
(no) | — gap |
Cache feature comparison
Section titled “Cache feature comparison”| Cache feature | Turbo | Nx | vite-task | vx |
|---|---|---|---|---|
| Local cache | tarball-per-hash in .turbo/cache |
.nx/cache SQLite-ish |
materialized-artifact crates | SQLite index + one <hash>.tar.zst per entry in a store every checkout of the repo shares (~/.vx/<id>/cache) |
| Remote cache wire | Vercel /v8/artifacts/ (HMAC, pre-signed) |
Nx Cloud or plugin | — | plugin-driven: @vzn/vx-reapi (Bazel AC/CAS), @vzn/vx-migrate’s turboCache() (/v8/artifacts, self-hosted or Vercel), @vzn/vx-migrate’s nxCache() (/v1/cache) |
| Log replay on hit | yes | yes | yes | yes |
| Output restore on hit | yes | yes | yes | yes |
| Output cleaning | (no — additive) | (no) | (materialized) | yes — wipe before exec AND before restore |
| Cache pruning | cacheMaxAge, cacheMaxSize in config |
maxCacheSize |
vp run cache clean |
vx cache clean --older-than / --max-size; no run evicts |
| Stats / run history | --summarize JSON files |
Nx Cloud dashboard | --last-details |
runs + invocations tables in cache.db (direct SQL); vx info; vx last |
| Per-run JSON summary | --summarize |
--outputStyle |
--last-details |
vx last --format json |
| Flaky task detection | — | Nx Cloud (paid) | — | local — same key failed after it passed: the task’s row, vx info |
| Chrome-trace profile | --profile |
(Nx Cloud) | — | --profile[=<path>] |
| Async remote prefetch | — | — | — | yes — stable-key GETs overlap execution |
| Restore-ahead scheduling | — | — | — | yes — two-tier scheduler restores warm hits ahead of their deps |
| Artifact integrity | HMAC x-artifact-tag |
(transport-level) | — | core refuses a bomb, a non-vx archive, or one recording another key; @vzn/vx-reapi re-hashes every blob |
| Pre-signed URL auth | yes | yes | — | plugin’s business — core ships the cache seam, not a transport |
Workspace integration
Section titled “Workspace integration”| Capability | Turbo | Nx | vite-task | vx |
|---|---|---|---|---|
| pnpm / npm / yarn / bun workspaces | yes | yes | yes | yes (@vzn/vx-lockfile: pnpm, npm, yarn, bun) |
| Non-JS projects (Rust, .NET, Gradle, …) | experimental (native Go, Cargo, uv) | yes (plugins) | no | yes (any directory with a vx.config; edges declared) |
| Filter DSL | pnpm-style + [<since>] (git-relative) |
yes via --projects/--exclude (no DSL) |
pnpm-style | pnpm-style + [<since>] |
| Affected / git-relative | --filter '[since...]', --affected |
full affected subcommand |
— | --affected[=<base>] + [<since>] |
| Daemon / persistent project-graph process | not for turbo run since 2.8.11 |
yes (on by default locally) | — | — out of scope |
| Watch mode | turbo watch |
nx watch |
— | vx watch <task> |
| Prune workspace (Docker subset) | turbo prune |
— | — | — gap |
Gap audit 2026-09-04 — what a developer would miss, core or plugin
Section titled “Gap audit 2026-09-04 — what a developer would miss, core or plugin”The owner’s question: which Nx 23 / Turbo 2.10 features are a MUST or
a game changer for developers, and of those, which belong in core.
Rule applied: if a plugin can do it through an existing seam, it is
not core. Every row below was checked against docs/cli.md,
docs/schema.md and the source, not remembered.
Verified present in core (parity or ahead): the task graph with
^task / wildcards / nearest-holder frontier; --filter as a superset
of Turbo’s DSL (..., ^..., !, ./dir, [git-ref]) and
--affected; caching with declared inputs, outputs, inputs.env,
workspace files; strict env isolation (Turbo’s
--env-mode=strict is vx’s only mode); persistent tasks with
readiness gating (ahead of Turbo’s persistent and Nx’s
continuous); the interactive picker; watch, --dry, --profile, --continue modes, retries, timeouts,
--output-logs modes; init (scripts to configs) and, its own
package, bunx @vzn/vx-migrate (Turbo, Nx); the cwd-scoped default;
last, why, show, info; remote cache and execution through the
seams (@vzn/vx-reapi).
Missing, and where it belongs:
| Feature | Nx / Turbo | Verdict |
|---|---|---|
Zero-config adoption: package.json scripts are tasks, no per-package file |
both, in core | SHIPPED 2026-09-09 as @vzn/vx-turbo, turbo() in @vzn/vx-migrate since 2026-09-11: a project-stage plugin over the same mapper vx-migrate renders from, so a turbo.json repo starts, temporarily, with only a .vx/workspace.ts; the seam widening core needed (visit a config-less package when a plugin declares project) landed with it. Nx followed 2026-09-22 (nx(), item 590): the same stage over Nx’s resolved graph, executor targets run as themselves through nx-exec. |
Inferred tasks from tool configs (vite.config ⇒ build/serve/test) |
Nx plugins | The project stage is the seam (a plugin visits every package, a config-less one as { tasks: {} }, and fills what the package did not declare); a first-party family was built and RETIRED the same day (2026-09-10, owner): technology plugins are the community’s, core names no tool. The recipe is in the plugins guide. |
.env files loaded into the task env |
Nx | Tasks read their own .env (Vite, Next do); the cache side is inputs.files: ['.env*', '.env.local', '.env.production.local', '.env.development.local']: .env* keys the tracked files, and each gitignored *.local one is named outright, since a glob skips a gitignored file (vx lock names one it skips). A config-stage plugin can inject. Not core; a docs footnote. |
Configurations (build:prod as one task, two modes) |
Nx | The language: a TS function returning the task per mode. Not core. |
| Cache size / age caps applied during runs | both | Removed 2026-10-18: a run never evicts (housekeeping at every run end was a cost); vx cache clean --older-than / --max-size from a schedule or CI bounds the cache. |
| Graph UI, TUI, dashboards | both | Rejected for core; --dry=json emits the data for a plugin or a site. |
Versioning and publishing (nx release) |
Nx | commands seam; changesets already exists. Not core. |
| Test splitting (Nx atomizer) | Nx | graph-stage plugin. Not core. |
Import boundaries (turbo boundaries) |
Turbo | A lint; out of scope. |
| Shell completions | both | SHIPPED 2026-09-10: vx completions bash|zsh|fish over the verb table and each verb’s help cut. |
| Windows | both | Under WSL (2026-09-10): POSIX shell is the API, and WSL is where it is on Windows. A native port is not proposed. |
Net: core is at parity or ahead on the must-haves; the gap that costs adoption is the trial with no generated files, and its core half is a small seam. Everything else is a plugin or the language.
Gaps for @vzn/vx (the running list)
Section titled “Gaps for @vzn/vx (the running list)”Ranked by leverage. Cited file paths are inside the respective upstream repos.
Likely-worth-adding
Section titled “Likely-worth-adding”-
Remote cache is PLUGIN-DRIVEN (owner directive 2026-07-10). Core carries zero HTTP cache code: it keeps the seams (
LayeredCache, theRemoteCacheLayerinterface, thecacheplugin capability,RunOptions.remoteCache), and a plugin ships the wire —@vzn/vx-reapispeaks Bazel’s ActionCache + CAS, so NativeLink, BuildBuddy, Buildbarn and bazel-remote all work. Turbo/v8/artifactscompatibility was DROPPED from core and came back as a plugin:turboCache()in@vzn/vx-migratespeaks the Turbo wire (self-hosted or Vercel) andnxCache()the Nx self-hosted one, both against the samecacheseam — the recipe for any other wire lives in the plugins guide. -
--continue=<mode>— shipped.--continue[=never|deps-ok|always]controls failure propagation:neverfail-fast (stop dispatch on the first failure),deps-ok(default) skip only a failure’s dependents while independent siblings continue,alwaysrun everything. Bare--continue=always. Enforced in the scheduler; seecli.md§ Failure propagation. -
Wildcards in
dependsOn— shipped.'build.*'expands to every other same-project task matching the pattern (zero matches legal);'^build.*'walks the nearest-holder frontier where a holder is a dep declaring ≥1 match and receives edges to ALL of them.*is the sole metacharacter; bare'*'/'^*'stay filter-only (inputs.tasks), and'pkg#pattern'is rejected. Seeschema.md§ dependsOn.- Nx 19.5+
build-*parity.
- Nx 19.5+
-
Workspace-level
globalInputs/globalEnv/globalPassThrough— owner-REJECTED (2026-07-05, “no global”). TypeScript configs compose: a shared preset imported and spread into each config IS the global-inputs/global-env mechanism (same rationale as the rejected named-inputs machinery — a schema field would duplicate the language). The@vzn/vx-migrateTurbo path already emits a generatedvx-preset.tsfor exactly this. Not a gap; will not be added.- Turbo
globalEnv,globalPassThroughEnv.
- Turbo
-
--cache-dir <path>CLI flag — shipped. Overrides thedefineWorkspace({ cache: { dir } })field + the.vx/cachedefault, resolved relative to cwd. A per-run knob; never folded into a cache key. -
Auto-input inference via filesystem tracing — owner-REJECTED (reconfirmed 2026-07-05, “no auto input”). Re-classified out of scope for vx (2026-06) after studying vite-task’s implementation. Doing this soundly is a multi-platform native systems project — vite-task ships ~9 Rust crates for it: LD_PRELOAD / DYLD_INSERT_LIBRARIES interposition for glibc/macOS, a
seccomp_unotifykernel supervisor for static binaries (esbuild, Go tools) that bypass libc, Microsoft Detours on Windows, a 4 GiB shared-memory IPC channel, and — because macOS SIP strips DYLD injection from Apple-signed binaries — their own shipped shell + coreutils to run commands under. Traced reads are re-validated at every cache lookup (not key-folded). vx cannot ship per-OS native helper binaries without abandoning its no-build-step distribution; explicit inputs stay the contract.- vite-task:
{auto: true}is the default; backed bycrates/fspy*.
- vite-task:
Maybe-worth-adding (heavier lift, narrower payoff)
Section titled “Maybe-worth-adding (heavier lift, narrower payoff)”-
--output-logs hash-only— shipped (2026-08-25). One line per task — outcome word, task id, cache key — with no log output; the run’s audit trail of which key each task resolved to. All four Turbo modes now covered.- Turbo:
--output-logs, schemaoutputLogs.
- Turbo:
-
Configurations (named option sets per target).
build:prodvsbuild:devas one task with two configurations rather than two tasks. vx’s answer today is the language: a TS function returning the task for a mode, spread into two tasks, plus a group over both.- Nx:
configurations+-c.
- Nx:
-
Pre/post script lifecycle — shipped in
vx init(2026-09-03).pre<x>/post<x>scripts fold intox’s command in npm order (tasks of their own where the manager runs no hooks) when the config is generated, so the ordering survives as a plain command rather than as runtime magic; a script that only delegates (npm run other) becomes a group overother. Not applied at run time: a task’s command is exactly what its config says.- vite-task:
enablePrePostScripts(default true).
- vite-task:
-
vx prune— shipped (2026-08-25),@vzn/vx-prunefrom 2026-09-10, removed 2026-09-11, back 2026-10-03 as a verb of@vzn/vx-lockfile, removed 2026-10-14. Workspace subset for Docker builds: targets + transitive workspace deps, workspace lists rewritten, root manifests and vx configs, each lockfile (bun, pnpm, npm, yarn) PRUNED to what the subset installs so it installs frozen,--dockerjson/full split for layer caching. The 2026-09 verb copied the lockfile unpruned.- Turbo:
turbo prune.
- Turbo:
-
Cache TTL / size caps in config — removed 2026-10-18.
cache.retention(item 658) evicted at the end of every run; a run now never evicts, andvx cache clean --older-than / --max-sizeis the one cleaner.- Turbo:
cacheMaxAge,cacheMaxSize(verified in the 2.10 configuration reference). - Nx:
maxCacheSize.
- Turbo:
-
Last-run replay — shipped (2026-08-25) as
vx last. Replays the most recent (or any recorded) run’s summary from the local history: header + per-task table,--listfor recent run ids,--format jsonfor scripting. Read-only overmetrics.ts.
Shipped since this list was first drawn
Section titled “Shipped since this list was first drawn”vx watch <task>— debounced re-run loop.--output-logs full|errors-only|hash-only|none.vx info.- Artifact integrity at the ingest boundary — core refuses a remote
artifact that decompresses past its ceiling, is not a vx archive, or
records a key other than the one it was fetched under;
@vzn/vx-reapire-hashes every CAS blob against its digest, andturboCache()verifies Turbo’s HMAC tag when given asignatureKey. - OTel run telemetry — the
otel()plugin in@vzn/vx-otel(declare it in.vx/workspace.ts+ setOTEL_EXPORTER_OTLP_ENDPOINT). - Per-task OS sandboxing (
exec.sandbox, SRT-backed, fail-on-violation). - Root-anchored inputs/outputs (
workspaceFiles) and runtime-command inputs (runtime/workspaceRuntime). vx lock(runs read only the lock),vx init/@vzn/vx-migrate,vx show.- The plugin pipeline (2026-09-02). One
VxPluginhooks every stage —config,discover,project,graph,key,fingerprint,measures,schedule,admit— beside theexecutor/cache/telemetry/outputcapabilities,commands(new verbs) and thesetup/teardownpair. Core applies no plugin by default; a workspace declares all of them. vx init(scripts → configs),vx why,vx last,--cache-download, remote execution through@vzn/vx-reapi,@vzn/vx-ci(job summary), thevx-historyplugin (critical-path priorities from recorded durations).- npm distribution as per-platform binary packages plus a launcher, signed on macOS.
Explicitly rejected (owner decisions — do not re-propose)
Section titled “Explicitly rejected (owner decisions — do not re-propose)”- Named / reusable input sets (
namedInputs) and target defaults / inheritance. TypeScript configs compose — shared presets via import ARE vx’s named inputs and defaults; schema machinery would duplicate the language.
Explicitly out of scope (today)
Section titled “Explicitly out of scope (today)”These don’t appear on the roadmap and won’t be added without a deliberate design pass.
- Daemon / persistent project-graph process. Re-discovery is fast
enough on Bun (a warm 1000-project run is ~170 ms end to end, with
config loading scoped and every config read from the lock);
the operational cost of a daemon doesn’t pay for itself. Turbo
reached the same conclusion: from 2.9 its docs say the daemon is no
longer used for
turbo run. - JS-function tasks. “Shell is the API” is a deliberate
constraint: a task is a command string. The
executorcapability decides WHERE that command runs (a worker, a sandbox, this machine) and the pipeline hooks shape what the graph contains and how it is keyed and ordered — none of them can replace the command with code. - Generators / scaffolding. Not a task-runner concern.
- TUI / interactive panes. Streamed framed blocks + the worker
status region are the terminal format; no Nx-style Terminal UI (an
attempt was built and dropped).
vx lastreplays a recorded run from the local history; anything browsable is a telemetry-plugin story now that the self-hosted dashboard is gone (2026-08-23). - Boundaries / package-tag visibility. Module-level constraint
rules belong in lint (
oxlint,eslint-plugin-import), not the task runner. - Non-JS executor plugins. A Rust / .NET / Gradle project is a
directory with a
vx.configwhose tasks call its own tool; vx ships no per-language plugin. - Native Windows. vx spawns POSIX shell. Binaries are built for linux/darwin × x64/arm64, and Windows runs the Linux one under WSL2; there is no native Windows build, by decision (2026-10-09).
Where vx is ahead
Section titled “Where vx is ahead”Things @vzn/vx does that the others don’t:
- A Vite-style pipeline instead of a feature list. Every stage
has a hook and every capability is a plugin, in declaration order,
with core re-validating what a hook returns. A plugin can fold a
value into every cache key (
key, andvx whyshows it by plugin name), rewrite a project’s tasks, add an edge, reorder the schedule, or add a verb — so “vx doesn’t do X” is answered with a plugin, not a fork. Turbo documents no plugin surface; Nx’s plugins infer tasks and add graph data, generators, migrations and executors. vx why— cache-key explainability. Per-component input fingerprints are recorded on every miss, so a re-run is explained by diffing two keys: which file, env var, upstream key or plugin part moved. Turbo’s--summarizeand Nx’s cache view show hashes, not the diff.- TypeScript config with full type inference — no string typos,
IDE autocomplete, presets as plain imports. The closest thing in
Turbo/Nx is
extends; in vite-task it’s tied to Vite’s config loader. - Resolved-config hash. Imports and computed values get folded into the cache key because the post-evaluation object is what we serialize. Turbo and Nx hash the static JSON file and miss anything computed at config-load time.
- Project
package.jsonas a plugin’s input. Core keys no package file (2026-10-14);@vzn/vx-lockfileadds each project’spackage.jsonto its cached tasks’ inputs, so narrowinputs.fileslike['src/**']doesn’t miss dep / version-bump invalidation. - Lockfile-aware invalidation as a plugin.
@vzn/vx-lockfileclaims the lockfile (pnpm, bun, npm, yarn) and keys each project on its own resolved closure (name, version, peers, integrity, patches,link:reach), sopnpm update foore-keys only the projects that reachfooand--affectedselects their tasks (and the tasks behind them). Nx does this inside its daemon’s project graph; Turbo hashes the lockfile changes that affect each package.vx lockrecords the digests and a run keys from that record, holding the lockfile to its hash, so no run parses for its key: a warm run pays a read and a hash. - Strict output ownership. Declared
outputs.filesare wiped before exec AND before cache restore. Both Turbo and Nx restore additively; stale files from a prior build survive a cache hit there. - Group tasks as first-class. A task with no
exec(justdependsOn) is a pure aggregator; doesn’t appear in the run output, isn’t counted in the summary, isn’t recorded in analytics. - Runtime-command inputs.
inputs.runtime/workspaceRuntimefold a probe command’s live output into the key (tool versions, OS info) — Nx hasruntime; Turbo has no equivalent (vercel/turborepo#4124). - Restore-ahead two-tier scheduling. A stable-key warm hit restores immediately, before its deps finish running, as worker backfill — misses still own the pool. Remote runs get the same idea as async prefetch (remote GETs overlap execution, at most one per key).
vx lock. Configs are programs; the lock freezes the resolved objects and every run reads only it, with a full re-evaluation audit (vx lock --check). No analog in Turbo/Nx.bunx @vzn/vx-migrate. One command generates per-packagevx.config.tsfrom Turbo or Nx, with TODO comments for everything unmappable.- A versioned telemetry contract + plugin seam.
TelemetryRecord/RunSummaryRecord(TELEMETRY_SCHEMA_VERSION) is one neutral export shape every sink reads — OTel (@vzn/vx-otel), the GitHub job summary (@vzn/vx-ci), or a custom sink — observe-only by construction, zero cost when unused. - Bun-native everything, and none of it the user’s concern.
Bun.spawnfor child rusage capture,bun:sqlite,Bun.YAML,Bun.Glob,Bun.hash.xxHash3,Bun.zstdCompress, nativeawait import()with a content-hash query string for config cache-busting. The binary embeds the runtime: no native-binary build step on install, no Node or Bun on the machine. - One-binary distribution.
bun build --compileproduces a single self-contained executable per platform target, published as per-platform npm packages behind a tiny launcher (the esbuild model) and as GitHub release assets — no Bun or Node needed at runtime. - Wallclock-ns analytics out of the box. Every task records
hrtime spans relative to the run’s t=0;
cache.db’srunstable is queryable withsqlite3directly;--profileexports Chrome-trace JSON without any additional setup. - Persistent tasks with regex-readiness.
readyWhen: 'Local:'for a dev server is a one-liner; downstream tasks unblock on ready, not on exit. Turbo’spersistentis more elaborate (separateinterruptible/withflavors); vx’s surface is smaller.exec.interactiveis Turbo’sinteractive, outside its TUI: on a TTY the task gets the terminal and runs alone. - Explicit
cache + persistentrejection. The project loader throws — no silent surprise.
Deliberate divergences from Turbo / Nx
Section titled “Deliberate divergences from Turbo / Nx”These are places where Turbo or Nx pin a specific behavior in their
test suites and vx deliberately does something else. Listed here so
the choices don’t drift accidentally — if any of these change, the
rationale below needs revisiting. Sourced from the full gap analysis
in design/turbo-nx-test-gaps.md.
Hashing pipeline
Section titled “Hashing pipeline”- Git blob OIDs for input files, like Turbo (v20). Tracked clean
files contribute their index OID (harvested from the same bulk
git ls-files -sspawn that enumerates files — zero reads on a clean tree); dirty/untracked files fall back to an in-process blob-OID computation that is byte-identical for identical content. The KEY composition on top of those per-file OIDs is xxHash3 (16-hex), not SHA — Turbo composes with xxh64; the widths match. - No
.gitattributesCRLF normalization in the fallback. Turbo’s manual-hash fallback replicates git’s CRLF conversion so it matchesgit hash-objectundertext=auto/autocrlf. vx’s in-process fallback hashes raw bytes (blob <len>\0+ content), and a file under a clean filter (text/eol/ident/filter/autocrlf) drops its index OID for that worktree hash, so the key follows the bytes the task reads (dropFilteredOids,cache/git-inputs.ts). - No
.gitattributesbinary detection. Same root: raw bytes; no text-vs-binary distinction needed at hash time.
Task graph
Section titled “Task graph”forwardArgsdoes NOT inherit intodependsOndeps. Nx forwards args/options into dependents viaoptions: 'forward'. vx scopesforwardArgsto user-requested nodes only — passingvx run build -- --foodoes NOT pollute upstream tasks’ cache keys. Explicit > magical (architecture principle #1).
Filter DSL
Section titled “Filter DSL”- Stacked
--filter name --filter [ref]is UNION, not intersection. Turbo’s discussion #9096 argues for intersection (“only packages that are both affected AND match the name”). vx unions (tests/filter.test.ts > applyFilters > stacked: --filter ui --filter [main] unions name + affected sets). Mental model: each filter ADDS to the selection; never narrows another filter’s set. - A bare name is an exact match; it never reaches into a scope.
Nx resolves
coreto@acme/core; vx’s--filter coreselects only a package named exactlycore. Name the scope (@acme/core) or lead with*(*core) (tests/filter.test.ts > applyFilters > scoped names).
Affected detection
Section titled “Affected detection”- Project removal does NOT invalidate every project’s cache. Nx
invalidates everything when a project is removed. A dependent’s key
folds its upstreams’ keys and, under
@vzn/vx-lockfile, its ownpackage.json, which catches “project gone” at finer granularity — only tasks that actually consumed the gone project are busted. git diff --no-renamesfor affected. vx flips rename detection OFF so cross-projectgit mvflags BOTH source and destination projects. Turbo’s default rename-on would surface only the destination, silently missing the source’s affected status.
Cache storage
Section titled “Cache storage”- stderr is not cached; stdout is stored twice on purpose. Turbo
embeds the run’s full logs inside the cache archive. vx’s artifact
is
stdout,outputs/,workspace-outputs/, a.vx-meta.jsonmode/mtime sidecar and a.vx-sumchecksum (only successful runs are cached and their stderr is near-always empty); stdout ALSO lives in the SQLiteentriesrow so a local hit replays it with pure SQL — never decompressing the artifact. - Per-run SCM metadata, not per-entry. Turbo writes the git sha +
dirty-hash into each cache entry’s metadata. vx records git
commit/branch/dirty on the per-run
invocationsrow instead, and the per-componententry_inputsrows answer “what inputs produced this artifact” at finer granularity.
Remote cache
Section titled “Remote cache”- No remote wire in core. Turbo ships its HTTP cache client
(bearer token, refresh on 403, preflight, timeout) inside the
binary; vx core ships the
cacheseam and nothing else. The wire, its auth and its deadlines belong to the plugin —@vzn/vx-reapispeaks Bazel’s ActionCache + CAS over gRPC with a per-call deadline on every cache-path RPC, and a remote error always degrades to a MISS rather than failing the run.
Glob walking
Section titled “Glob walking”- A symlink is a link, not its target. Turbo distinguishes
shallow-wildcard vs doublestar follow-link behavior explicitly. vx
scans with
followSymlinks: falseand folds a link’s target STRING, as git does (cache/inputs.ts); since Bun 1.4.0 a scan can still descend a linked directory, so an output path whose directory resolves outside the project is refused before any delete. The symlink-cycle test (tests/inputs.test.ts) pins that the resolver doesn’t hang. - Standard globs. As in Turbo and Nx,
[id]in a task glob is a character class and\[id\]names a route directory, so a turbo.json or Nx input glob means the same files underturbo()andnx().
Engine / scheduling
Section titled “Engine / scheduling”- No executor batching. Nx batches same-executor tasks into a single child process. vx’s executor is a PLACEMENT seam — it decides where one command runs, never merges commands — so every task is its own process. Tradeoff: more spawn overhead; far simpler model.
- No incremental watcher state. Turbo’s watcher maintains rich
incremental change-accumulator + rediscover state. vx re-runs the
orchestrator from scratch on each cycle. Cheap because of
gitFilesCache+Cache.hashFilemtime+size fast path; complexity not yet justified.
Config schema
Section titled “Config schema”- No
$WORKSPACE_ROOT$/$TURBO_ROOT$token substitution. Turbo + Nx use template tokens in path strings; vx uses real paths from the project-dir context. The path resolution context is unambiguous because every glob is scoped per-project.
Env handling
Section titled “Env handling”- No
.envauto-loading. Nx auto-loads.envfiles. vx requires explicitinputs.envdeclarations. “Explicit over magical” (architecture principle #1). - No wildcards in
inputs.env. Turbo supportsVERCEL_*expansion. vx rejects wildcards at load time so a typo doesn’t silently contribute an empty value to the cache key (pinned by tests/project-loader.test.ts > rejects wildcards in inputs.env).
Concurrency model
Section titled “Concurrency model”- Single-event-loop JS, no shared mutex. Turbo uses
RwLock<TaskHashTracker>and tests concurrent reads + read/write. vx is single-threaded JS by construction — no shared mutable state across “threads” to race over.