Skip to content

How vx works

This page builds the mental model. Once you understand the five stages of a run, the config and the caching behavior stop being surprising. For the blow-by-blow version, see the Execution lifecycle reference.

When you type vx run build, vx:

  1. Discovers the workspace and its projects.
  2. Loads the relevant vx.config.ts files.
  3. Builds the task graph from dependsOn and your package dependencies.
  4. Hashes each task’s inputs into a cache key.
  5. Schedules the graph — for each task, a cache lookup decides restore vs. execute, running as many tasks in parallel as your concurrency allows.

vx finds the workspace root (a pnpm-workspace.yaml, a workspaces field, or a bare package.json) and the packages in it. A package becomes a project with runnable tasks only when it has a vx.config.ts. vx loads only the configs in scope for the run plus their dependency closure — a single-package run doesn’t pay to evaluate 1000 configs.

Each vx.config.ts is real TypeScript, evaluated by Bun directly (no build step). This is why imports, presets, and computed values work — and why vx hashes the resolved object, not the file text. A shared preset that changes invalidates every task that uses it.

vx turns your tasks into a single directed graph:

  • Same-package dependsOn edges ('codegen').
  • Cross-package edges from '^build', resolved against your package.json dependencies (bridging packages that don’t declare the task to the nearest one that does).
  • Explicit 'pkg#task' edges.

Cycles are detected here and reported with the offending path.

For each task, vx derives a content hash from everything it depends on: the declared input files (read via git’s index), tracked env values, the resolved command, the project’s package.json, the workspace lockfile, forwarded -- args, and the hashes of upstream tasks. Same inputs → same key → the work has been done before.

Reading file identity from git’s index is what makes hashing nearly free: on a clean tree, deriving every key costs zero file reads and zero stats — the blob OIDs are already there.

The scheduler walks the graph in topological order, running independent tasks in parallel up to your concurrency. For each task:

  • Cache hit → restore the stored outputs and replay the logs. (With a remote cache configured, vx checks local first, then remote, hydrating local on a remote hit. Remote lookups are prefetched in the background so latency overlaps execution.)
  • Cache miss → wipe the declared outputs, run the command, then store the new outputs and logs under the key.

A failed task aborts its dependents but lets independent siblings finish. Persistent tasks (dev servers) are started, gated on readiness, and SIGTERM-ed when the run ends.

Two design choices make hits safe to trust:

  • Strict output ownership. Declared outputs are wiped before both execution and restore, so your tree ends every run bit-identical to the cached snapshot — no stale leftovers.
  • Resolved-config hashing. The key is derived from the evaluated config object, so a change to a shared preset or a computed value correctly invalidates every task that uses it — something static-JSON config can’t see.

(vx keys purely on inputs — it does not fold an upstream’s output content into downstream keys. An upstream that re-runs re-runs its dependents, the same model as Turborepo and Nx.)

Everything above is computed fresh each run from the filesystem and git, cheaply enough that a background process buying “warm graph state” isn’t worth its cost — the staleness windows, the socket state, the reset ritual. vx is cold-start fast by construction. The mechanics behind that are in Why vx is fast.