Skip to content

Turbo vs Nx vs vx — operation-by-operation breakdown

Status: reference / context doc (2026-05). Captures what each runner does at each step of a run invocation. Updated when our implementation moves; the Turbo / Nx columns are pinned to source revisions called out per row.

Sources verified in this session:

  • Turbo: /tmp/turbo/crates/turborepo-* (rev 71f8c90)
  • Nx: /tmp/nx/packages/nx/src/* (rev 962f146)
  • vx: this repo at main

Daemon paths in Nx are excluded; we are explicitly daemonless.

PhaseTurboNxvx
Cold-start cacheDaemon-backed; cold path re-discovers workspaceDB-cache + on-disk project-graph snapshotCold every run — no persistent state
Workspace discoverypackage.json walk + lockfile parsePlugin pipelinepnpm-workspace.yaml / pkg.json
Task graphTopo build, RustTopo build, TS + Rust hashingTS, buildTaskGraph
Input enumgit ls-files per package, dedupe across tasksNative Rust (hash_array, batched)One git ls-files at workspace root, partitioned
Input hashingxxh64 in Rustxxh3 in Rust (native)xxh3 in Bun (xxHash3 via Bun.hash)
Cache keyxxh64 → 16 hexxxh3 nativexxh3 → 16 hex (seed-chain folded)
Cache lookupSQLite + tar.zst on diskSQLite (DbCache.getBatch) — one querySQLite + tar.zst — per-task
Restore (warm)Per-file skip via sibling <hash>-manifest.jsonAlways extract (no per-file skip)Skip via output_files SQLite + stat-check
Restore (cold)Tar.zst stream extract, parallel writesRust copyFilesFromCacheIn-process tar parse + Bun.write
Savetar.zst + sibling <hash>-manifest.jsonRust storeArtifactInCachetar.zst + SQLite output_files rows
Log replayBuffer per task, emit on completeBuffer per task, emit on completeSame — defaultLogger per-task buffer
Integrity (local)xxh64 of compressed bytes? No (verified absent)Machine-ID gate + checksum-less artifact restoreNone — gap (see audit doc)
Integrity (remote)HMAC-SHA256 over hash‖team‖bytes, gated by envNo HMACShipped 2026-06: same scheme, VX_REMOTE_CACHE_SIGNATURE_KEY
Signal handlingCancel token + SIGTERM via tokioIPC signal forwarding to childrenShipped 2026-06: run() SIGTERMs live children, exits 128+signo
FS robustnessRetries unclear / inherits OStryAndRetry() exponential backoffNo retries beyond SQLite busy_timeout

The rest of this doc is per-phase deep-dives. Cells call out what they do, where in source, and whether we should adopt.


StepTurboNxvx
Process bootstrapSingle Rust binary; ~30ms coldNode + napi-rs load; ~150ms coldBun --smol TS execute; ~80ms cold
Workspace root findWalk up until lockfile + package.json (turborepo-repository)Walk up to nx.json / workspace root (workspace-root.ts)findWorkspaceRoot in src/workspace/workspace.ts — pnpm-workspace.yaml OR pkg.json workspaces
Config loadturbo.json parsed once, validated against schemanx.json + every project’s project.json via plugin pipelinevx.workspace.{ts,mts,js,mjs} + every project’s vx.config.* (Bun.import each, parallel)
Daemon attachTries to attach to running daemon; falls back to coldTries to attach; falls back to coldN/A — no daemon
Project graphRust struct, plugin-derivedProjectGraph cached as JSON snapshot on disk (workspaceDataDirectory) — invalidated by mtime checks on project.json filesbuildPackageGraph from pnpm-workspace.yaml — no cross-run cache

Gap for vx: no cross-run project-graph cache. Each cold start re-imports every vx.config.ts. PR #84’s hashCache is within-run only. Adopting a Nx-style disk snapshot would save ~10-50ms × N projects on cold starts; needs mtime invalidation logic.


StepTurboNxvx
File discovery (in git repo)git ls-tree -r -z HEAD once at repo root, sorted, range-queried per packageNative Rust: full filesystem walk, ignoring .gitignoregit ls-files --cached --others --exclude-standard -z once at workspace root, partitioned by project prefix (PR #90)
File discovery (not git)walkdir + gitignore filterSame Rust walkBun.Glob walker + ignore lib (fallback)
Per-task glob matchwax crate against the deduplicated file setNative Rust matcherPer-task Bun.Glob.match against the per-project memoized file list
Dedup across tasksYes — file-hash cache keyed by (package, input_globs, default_flag) (turborepo-task-hash)Yes — hashArray cached nativelyYes — per-run hashCache (PR #84) + per-file file_hashes table

vx state: good. Bulk git enumeration shipped in PR #90. Per-file mtime+size memo in SQLite (PR #87). Both match Turbo’s architecture.


StepTurboNxvx
Per-file hashxxh64, sometimes batched via RayonNative Rust xxh3 (hashFile())xxh3 (Bun.hash.xxHash3) with mtime+size fast path
Stale-mtime fast pathgit-blob OID when in-tree (no re-read needed)Per-file mtime+size cache (in-daemon)SQLite file_hashes(path, mtime, size, content_hash) row per file
Big-file streamingYes (Rust streams)Yes (native)NoBun.file().bytes() loads whole file. Bun.hash.xxHash3 lacks streaming API

vx gap: no streaming hash for large files. Today inputs are source files (≤1MB typical) so this doesn’t bite. If users ever hash GB-scale assets, would need a different hasher (Bun.CryptoHasher streaming + tag with version bump).


FieldTurboNxvx
Schema version sentinelYes (turbo version)YesCACHE_VERSION = 'vx-cache-v15'
Task identitypackage_name#task_nameproject:targetproject#task
Workspace fingerprintResolved lockfile + workspace defsHashed package.json + lockcomputeWorkspaceFingerprint hashes lockfile + pnpm-workspace.yaml
Project pkg.jsonImplicit dep via lockfile-derived dep graphHashed per projectprojectPackageJsonHash (PR #42)
Task configturbo.json subset for this taskresolved project.json targethashTaskConfig over resolved TaskConfig
Forwarded CLI argsYesYes (task.overrides)forwardArgs, scoped to requested tasks (PR #17)
Env captureenv + passThroughEnv whitelists, globalEnv fallbackPer-task env via inputs.envcache.inputs.env list — values folded
Upstream task hashesYes, filtered by dependsOnYes, filtered by inputs.tasksfilterUpstreamHashes with Turbo/Nx micro-syntax (PR #56)
Input file hashesSorted (path, hash) pairsSameSame — sorted inputFiles + per-file content hashes
Final hashxxh64 → 16 hexxxh3 (native)xxh3 seed-chain → 16 hex (PR #87)

Field-level parity: essentially identical. The fold ORDER and exact bytes differ (so keys aren’t cross-runner compatible) but the SET of inputs is the same.


StepTurboNxvx
Lookup mechanismSQLite + on-disk tar.zst named by hashDbCache.getBatch(hashes) — one SQL query for all tasksPer-task Cache.get(hash) — SQL SELECT + tar I/O
Batched?Per-runYes — explicit batch API in NxCache.getMetaBatch(hashes) exists (PR #92) but orchestrator still uses per-task path
Tar I/O on lookupYes — opens artifact for header readNo — metadata-only from DBYes — get() decompresses for stdout/stderr
Remote fallbackAsync, parallel with local read attemptsParallel Promise.all over remote missesSequential local→remote in LayeredCache.get

vx gap: getMetaBatch exists but isn’t wired into the orchestrator’s hot path. Doing so requires the upfront-hashing refactor that broke correctness when inputs include sibling outputs (see audit doc, item #3). The per-cache-hit loadOutputFilesBatch([hash]) we shipped in PR #95 is the realistic compromise.


6. Cache restore (warm — outputs already match)

Section titled “6. Cache restore (warm — outputs already match)”
StepTurboNxvx
Detect “tree already current”Per-file (size, mtime_nanos, mode) via sibling <hash>-manifest.jsonNo detection — always extractsPer-file (size, mode, mtime) check via output_files table + stat (PR #95)
Stray detectionNo (tar-extract overwrites)NoYes — set-equality check between resolveOutputs glob walk and DB rows
Skip path costRead+parse <hash>-manifest.json (small JSON, no decompress) + N statsN/AOne SQL SELECT … WHERE entry_hash IN (?) + N stats + 1 glob walk
Tar I/O on skipZero (manifest is its own file, sibling to tar)N/AZero (manifest in DB)

Turbo source (corrected from initial first-pass agent report): crates/turborepo-cache/src/cache_archive/restore_manifest.rs defines RestoreManifest with a HashMap<String, FileEntry> of (size, mtime_nanos, mode, is_dir). Persisted at <cache_dir>/<hash>-manifest.json via write_atomic() (line 161) and loaded via read() (line 156). Sibling to <hash>.tar.zst, not inside it.

vx vs Turbo at this step: essentially equivalent on the warm path — both avoid tar I/O entirely, both stat per file. We use SQLite, Turbo uses a per-hash JSON file. The benchmark from earlier in this session (SQLite vs JSON-in-tar) doesn’t apply to Turbo’s actual approach; we should re-bench against JSON-on-disk to know if there’s a real difference.

vx vs Nx: we’re ahead — Nx has no per-file skip at all on the restore path; every cache hit re-copies.


7. Cache restore (cold — files differ or missing)

Section titled “7. Cache restore (cold — files differ or missing)”
StepTurboNxvx
DecompressRust zstd stream, parallel via rayonRust copy via copyFilesFromCacheBun.zstdDecompress whole-archive (in-memory)
Tar extractRust tar crate streaming, writes via std::fsNative RustIn-process JS parser + Bun.write Promise.all (PR #94)
Path safetyLexically canonicalize each entry, reject escapesTrusts native codeNo checkpath.join(destDir, rel) (audit doc item #2)
Mode preservationYesYesYes — chmod after write
mtime preservationYes (nanosecond)YesYes (second precision via tar header)
stdout/stderr replayCached log files extracted alongsideStored as separate text filesBundled in tar.zst as stdout/stderr entries

vx gap: path-traversal check missing. Theoretical today (we control the tar contents) but defense-in-depth (audit doc item #2).


StepTurboNxvx
Spawnstd::process::Commandfork() (Node IPC) or spawn() for shell tasksBun.spawn with stdout: 'pipe', stderr: 'pipe'
PATH augmentationWorkspace .bin + each project’s node_modules/.binSameProject’s own node_modules/.bin prepended (PR #46)
stdout/stderr captureStreamed to buffer + cache fileStreamed to buffer + cache fileStreamed to logger buffer (per-task)
Signal forwarding to childTokio cancellation token → SIGTERMIPC signal forwarding (forked-process-task-runner.ts:411)All live children (one-shot + persistent) via run-scoped liveChildren set (shipped 2026-06)
Exit code propagationYes, fail-fast optionYes, --continue=<mode>Yes — see comparison.md for --continue gap
Resource accountingcpuTime, maxRSS via wait4cpuTime via subprocess eventsBun.spawn + resourceUsage() — cpu_ms, peak_rss_bytes recorded (PR #20)

vx gap: SIGINT/SIGTERM handler in run() doesn’t propagate to in-flight one-shot tasks (audit doc item #1). Shipped 2026-06: run() installs SIGINT/SIGTERM handlers (removed in a finally), SIGTERMs every live child, closes the cache, exits 130/143.


StepTurboNxvx
Output discoveryGlob walk per task’s declared outputsGlob walk via RustresolveOutputs(globs) — Bun.Glob walker with project-boundary excludes
Stage to tempYes (in-memory or temp dir depending on size)Yesmkdtemp then Bun.write each output
Tar buildRust tar crate streamingNative RustSubprocess tar -cf - -C stage outputs stdout stderr (could be Bun.Archive — benchmarked slower)
Compresszstd via Rust cratezstd via RustBun.zstdCompress on the tar bytes
Atomic publishtmp + renametmp + renametmp .tmp-<pid>-<ts> + rename (PR #86)
Metadata writeSQLite insert in same transactionSQLite insert via RustSQLite entries + output_files rows in one db.transaction (PR #95)
Remote uploadBackground, fire-and-forgetBackgroundLayeredCache.save fires remote PUT async; errors logged not propagated (PR #13)
Sign artifact (remote)HMAC-SHA256 over hash+team+bytes (env-gated)NoShipped 2026-06: Turbo-compatible scheme, gated by VX_REMOTE_CACHE_SIGNATURE_KEY

vx gap (closed 2026-06): HMAC on remote artifacts shipped — same construction as Turbo, plus GET-side verification (which Turbo also does; vx additionally hard-fails on a missing tag).


| Step | Turbo | Nx | vx | | ------------------- | ---------------------------------- | --------------------------- | ----------------------------------------------------------------------------------------- | ----- | ---------------------------- | ----------------------------------------------------- | | Capture during exec | Streamed, per-line buffered | Streamed, per-line buffered | Streamed chunks appended to per-task buffer in defaultLogger | | Replay on hit | Whole-block write to terminal | Whole-block write | One process.stdout.write per task in taskComplete() (already optimal — same as Turbo) | | Output mode flag | --output-logs=full | errors-only | hash-only | none | Similar via --output-style | Missingcomparison.md calls this out as a gap | | Color preservation | Yes — raw ANSI buffered + replayed | Yes (with TUI strip option) | Yes — colors via colors.ts, no strip | | Per-task framing | Block headers + indent | Block headers + indent | formatTaskBlock framed output |

vx gap: --output-logs flag missing — already in comparison.md backlog.


StepTurboNxvx
FS watchnotify crate via daemonchokidar + native watcherfs.watch(projectDir, { recursive: true }) per project + workspace root, debounced 150ms (PR vx watch)
Change classificationFull re-graph or incrementalIncremental task invalidationRe-runs the orchestrator from scratch on each batch
Affected-task subsetComputed in daemonComputed via project graph diffNo subset — re-runs all requested tasks; relies on cache hits to skip unchanged

vx state: simpler model (cache catches the no-op case). Adopting affected-task pruning would speed up “many tasks, one file changed” cases by skipping the cache-hit overhead entirely. Probably not worth the complexity.


StepTurboNxvx
Dep of failed taskSkipped (cascade)Configurable via --continue modeSkipped; siblings still run (PR #46 dropped fail-fast)
Sibling tasksContinue by defaultContinue or stop based on --continueAlways continue (no --continue flag)
Failed task logsReplayed at end-of-run footerReplayedStreamed live, NOT replayed at end (PR #46 dropped end-of-run replay)
Stderr capture on throwYesYesYes — scheduler catches throws, parks message on TaskOutcome.stderr (PR #17)
Persistent task cleanupSIGTERM on rest-of-graph-finishSameSIGTERM via persistentRegistry (PR persistent tasks)
Mid-run Ctrl+CCancel token propagatesIPC signalSIGTERM to all live children + exit 128+signo (shipped 2026-06)

vx gap: mid-run Ctrl+C handling (audit doc item #1). Shipped 2026-06.


ConcernTurboNxvx
SchemaPer-entry rows + run-historycache_entries + run-history + flake trackingentries + runs + file_hashes + output_files (PR #95)
ConcurrencyWAL + busy_timeout via napi-rsSameWAL + busy_timeout = 5000 (PR #17)
Transient retryOS-level via Rust cratetryAndRetry() exponential backoff (audit doc item #6)None — single failure kills the run
Schema migration”Pre-alpha or stable” with proper migrationsSamePre-alpha — DROP + CREATE on SCHEMA_VERSION change
file_hashes cache reusegit-blob-OID-based when in gitDaemon-resident memoDisk SQLite, survives across runs (PR #84)

vx gap: no transient retry (audit doc item #6).


14. Integrity (already enumerated in audit doc)

Section titled “14. Integrity (already enumerated in audit doc)”
MechanismTurboNxvx
Local artifact corruption detectNoNoNo (audit doc item #3)
Remote artifact tamper detectHMAC-SHA256 env-gatedNoYes — shipped 2026-06
Path-traversal in tar extractLexical canonicalizationNative trustNo check (audit doc item #2)
Machine-ID gate (cross-machine)NoYes (machine_id hash + env-gated rejection)No (audit doc item #5)
Symlink restore orderTopologicalNativeWe don’t restore symlinks

  • Onboarding context — a new contributor can see exactly where we mirror Turbo / Nx and where we deliberately differ.
  • Audit anchoring — when claims like “we should do what Turbo does for X” come up, this table is the answer-of-record; if the claim doesn’t match, we update the table OR the implementation.
  • Backlog grounding — every “gap” cell here links to either comparison.md (feature gap) or integrity-audit-2026-05.md (correctness gap).

When main moves, the vx column moves with it. Turbo/Nx columns are pinned to the source revisions noted at the top.