Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Compare the Nx approach

Choosing a Monorepo Task Runner

Picking the wrong task runner costs months: you either over-invest in tooling a three-package repo never needed, or you outgrow a thin orchestrator the moment your CI exceeds twenty minutes. This guide compares Turborepo and Nx head-to-head — task graph model, caching, affected/incremental execution, config ergonomics, plugin and codegen ecosystem, language coverage, and migration cost — and tells you exactly which to pick, with honest notes on Lerna, Lage, and plain package-manager scripts.

A task runner sits one layer above your package manager. Where your package manager resolves and links dependencies, the task runner decides what to run, in what order, and whether it can skip work it has already done. This is a core piece of Monorepo Architecture & Orchestration, and the decision interacts directly with how you do pnpm Workspace Filtering and how you configure Remote Caching Setup.

The Core Problem a Task Runner Solves

In a monorepo with twenty packages, pnpm -r build runs every package's build, every time, in topological order — but it never skips a package whose inputs have not changed, and it parallelizes only crudely. The result is linear growth: more packages means longer builds, even when a commit touches one file.

A task runner fixes three things at once:

  1. Ordering. It models tasks as a Directed Acyclic Graph (DAG) so lint, test, and build run in correct dependency order while maximizing parallelism across independent packages.
  2. Caching. It hashes each task's inputs (source files, dependency outputs, env vars, the task definition itself) and reuses prior outputs when the hash matches — locally and, optionally, across a team via a shared backend.
  3. Affected detection. It computes which packages a Git diff actually impacts, so CI runs only the affected subgraph instead of the whole repo.

Turborepo and Nx both deliver all three. The differences are in how much else they do, and how much configuration and conceptual overhead that costs you.

Decision tree for choosing a monorepo task runner A branching flow from repo size and language mix through caching needs to a recommended task runner. Start: pick a task runner Mostly JS/TS, want minimal config? Polyglot or want generators & enforced boundaries? Just need ordering + caching on top of pnpm scripts? Choose Nx Choose Turborepo no yes
A first-pass decision tree: language mix and config appetite usually settle the Turborepo-vs-Nx question before benchmarks do.

Task Graph Model

Both tools build a task DAG, but they infer it differently.

Turborepo derives the package graph from your workspace dependencies (the dependencies and devDependencies of each package's package.json). Task relationships are declared explicitly in turbo.json using the ^ prefix to mean "this package's task depends on the same task in its workspace dependencies." This is intentionally thin: Turborepo trusts the package graph you already maintain and layers task ordering on top.

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"]
    }
  }
}

Nx builds a richer project graph that goes beyond package.json. It statically analyzes import statements (TypeScript paths, import/require, even some asset references) to infer implicit dependencies that are not declared as workspace packages. This catches cases where apps/web imports from libs/ui via a TS path alias without a package.json dependency edge. Targets are configured per-project in project.json or inferred by plugins.

{
  "name": "ui",
  "targets": {
    "build": {
      "executor": "@nx/vite:build",
      "dependsOn": ["^build"],
      "outputs": ["{projectRoot}/dist"]
    }
  }
}

The trade-off: Nx's inferred graph is more accurate and catches hidden coupling — which is invaluable when debugging issues like those covered in Debugging Circular Dependencies in Monorepos — but it requires Nx to understand your file types, which is why language and framework plugins matter so much in the Nx world.

Caching: Local and Remote

Caching is the headline feature for both, and the mechanics are similar.

Each tool computes a hash from the task's inputs: source file contents, the resolved outputs of upstream dependencies, declared environment variables, the task configuration, and the tool/lockfile versions. If the hash matches a stored entry, the tool replays the cached terminal output and restores the output files (dist/**, coverage reports, etc.) instead of re-running the task. A cache hit on a clean tree turns a 90-second build into a sub-second restore.

The critical correctness rule for both tools: declare your inputs and outputs precisely. Under-declaring outputs means cache restores produce an incomplete dist. Over-declaring inputs (e.g. including README.md in a build's inputs) causes spurious cache misses. Forgetting to list an environment variable that changes output is the classic source of "works on my machine" cache bugs — and the leading cause of the symptoms in Fixing Turborepo Remote Cache Misses.

Remote caching shares those hashed artifacts across machines — your CI runners and your teammates. The first runner to build a package uploads the artifact; everyone else downloads it. Turborepo ships with a hosted backend (Vercel) out of the box and supports a custom HTTP endpoint for self-hosting. Nx provides Nx Cloud (hosted, with extras like distributed task execution) and self-hosted cache options. Both gate writes behind an auth token (TURBO_TOKEN, NX_CLOUD_ACCESS_TOKEN) to prevent cache poisoning. See Remote Caching Setup for the backend mechanics and Optimizing Turborepo Remote Cache for CI for tuning hit rates.

One meaningful divergence: Nx Cloud offers Distributed Task Execution (DTE), which farms individual tasks out across multiple CI agents and recombines results. Turborepo does not ship an equivalent first-party feature; you parallelize by sharding across jobs yourself. For very large repos this is a genuine Nx advantage.

Affected / Incremental Execution

Both tools can run only the work a change touches.

Turborepo uses turbo run build --filter='...[origin/main]' to scope to packages changed since a base ref plus their dependents. The --filter syntax mirrors the workspace traversal operators you may already know from Using pnpm --filter for Targeted Builds.

turbo run lint test build --filter='...[origin/main]'

Nx uses nx affected with an explicit base/head:

nx affected --target=build --base=origin/main --head=HEAD

Functionally these overlap, but Nx's affected calculation rides on its richer project graph, so it catches implicit dependents that a pure package.json-graph tool can miss. The flip side is that in practice both, configured correctly, produce equivalent CI savings on most JS/TS repos. The detailed setup for the Nx side lives in Configuring Nx Affected Commands in CI, and if raw speed is your deciding factor, read Nx vs Turborepo Performance Benchmarks before committing.

Config Ergonomics

This is where the two diverge most in day-to-day feel.

Turborepo is one file. A single root turbo.json declares every task, its dependencies, inputs, and outputs. You read it top to bottom and understand the whole pipeline. There is almost no per-package configuration beyond the scripts already in each package.json. Onboarding a teammate takes minutes. The full schema is documented in Turborepo Pipeline Configuration.

Nx spreads configuration across nx.json (global defaults, named inputs, cache settings, plugins) and per-project project.json files (or inferred targets from plugins). It is more powerful and more granular, but there is more to hold in your head, and "where is this target actually defined?" is a real question on a large Nx repo because targets can come from project.json, package.json, or a plugin's inference. Nx's nx graph visualizer and target-inspection commands exist precisely to answer that.

If your team values "the whole build is in one obvious place," Turborepo wins ergonomics. If your team values "the tool knows my projects deeply and can generate the config," Nx wins.

Plugin & Codegen Ecosystem

This is Nx's defining strength and Turborepo's deliberate non-goal.

Nx generators scaffold code: nx generate @nx/react:library ui-buttons creates a fully wired library — project config, build target, lint, test, TS paths, and tags — following your workspace conventions. Generators (and their counterpart, executors, which run tasks) are first-party for React, Next.js, Vue, Angular, Node, Express, NestJS, and more, and you can write your own. Nx also offers nx migrate, which updates dependencies and runs codemods to migrate your config across major versions automatically. For large org repos with many similar packages, this enforces consistency at scale.

Turborepo has no generator framework of its own beyond lightweight turbo gen scaffolding from your own templates. It does not try to own code generation, framework integration, or automated migrations. Its philosophy is to do task orchestration and caching well and stay out of everything else.

If automated codegen and managed upgrades matter to you, that is a strong reason to choose Nx. If you consider them scope creep, that is a strong reason to choose Turborepo.

Language Coverage

Turborepo is JS/TS-centric. It will happily run any command (including cargo build or go test) as a cached task, because it treats tasks as opaque scripts with declared inputs/outputs. But it offers no language-specific intelligence — no graph inference for non-JS code, no generators.

Nx is explicitly polyglot. Its graph inference and plugin ecosystem extend to other ecosystems (Go, Rust, .NET, Java via community and first-party plugins), making it the stronger choice for a genuinely mixed-language monorepo where you want a single tool to understand the whole repo's graph.

For a pure JS/TS shop, this difference is moot. For a platform team running services in several languages, it can be decisive.

Decision Matrix

Criterion Turborepo Nx
Task graph source Workspace package.json deps + explicit turbo.json Inferred project graph (analyzes imports) + per-project config
Local caching Yes Yes
Remote caching Built-in (Vercel) + custom HTTP self-host Nx Cloud (hosted) + self-hosted options
Distributed task execution No first-party DTE; shard manually Yes, via Nx Cloud DTE
Affected detection --filter='...[base]' nx affected --base --head
Config surface Single root turbo.json nx.json + project.json / inferred targets
Code generators Minimal (turbo gen from your templates) Rich first-party generators per framework
Automated migrations No Yes (nx migrate codemods)
Language coverage JS/TS-first; runs any command opaquely Polyglot via plugins
Conceptual overhead Low Moderate to high
Time to first value Minutes Hours (more so with plugins)
Best fit JS/TS repos wanting fast, simple orchestration Large/polyglot repos wanting structure, codegen, and managed upgrades

Choose Turborepo If…

  • Your monorepo is mostly or entirely JavaScript/TypeScript.
  • You want the entire pipeline in one readable file and near-zero per-package config.
  • You already maintain accurate workspace dependencies and trust that graph.
  • You want caching and affected builds working today with minimal learning curve.
  • You are on Vercel (or happy to self-host a simple HTTP cache) and don't need distributed task execution.
  • You view code generators and managed migrations as out of scope.

Choose Nx If…

  • You run a large or polyglot monorepo where one tool should understand the whole graph.
  • You want generators to scaffold consistent libraries and apps, and nx migrate to automate dependency upgrades.
  • You need enforced module boundaries (the @nx/enforce-module-boundaries lint rule) to keep architecture from rotting — closely related to Cross-Package Dependency Management.
  • You want distributed task execution to spread CI across many agents.
  • Your team will invest the up-front time to learn the model in exchange for deeper tooling.
  • See Nx Workspace Architecture for the full setup.

A Note on Lerna, Lage, and Plain Scripts

  • Lerna was the original JS monorepo tool. Modern Lerna is now maintained by the Nx team and uses Nx's task runner under the hood for caching and parallelism. If you have an existing Lerna repo, you are effectively already on a path to Nx; new repos rarely start with Lerna for orchestration, though its versioning/publish commands still see use.
  • Lage (from Microsoft) is a lightweight, pipeline-based runner conceptually close to Turborepo, popular in the Microsoft ecosystem. It is a reasonable choice but has a smaller community than the two leaders.
  • Plain package-manager scriptspnpm -r --workspace-concurrency, npm run with topological ordering — are genuinely the right answer for small repos (roughly under five to eight packages with fast builds). They have zero added dependencies and zero new concepts. Adopt a task runner only when you feel the pain: builds creeping past a few minutes, CI redoing unchanged work, or ordering bugs. Don't add Nx or Turborepo to a repo that doesn't hurt yet.

Migration Cost

Adopting Turborepo onto an existing pnpm/npm/yarn workspace is low-friction: install it, add a turbo.json, prefix your existing scripts with turbo run, and you have caching. You rarely change package structure. Backing out is equally cheap — delete turbo.json and you are back to plain scripts.

Adopting Nx is a larger commitment. Even the non-invasive "add Nx to an existing workspace" path introduces nx.json and project-level config, and to get full value (generators, inferred targets, boundaries) you adopt Nx conventions more deeply. Backing out of a deeply-adopted Nx repo is real work. This asymmetry is itself a decision input: Turborepo is a reversible bet, Nx is a more committed one.

Pitfalls

Mistake Impact Resolution
Adopting a task runner on a tiny repo Added config and a dependency that buys nothing Stay on pnpm -r until builds or CI actually hurt
Under-declaring task outputs Cache restores leave an incomplete dist, breaking downstream tasks List every output dir/file the task produces
Omitting env vars from task inputs Stale cache hits across environments produce wrong artifacts Declare all output-affecting env vars in env/named inputs
Treating Turborepo's graph as automatic Missing package.json dep edges hide real ordering dependencies Keep workspace deps accurate, or use Nx's inferred graph
Choosing Nx for a 4-package JS repo "to be safe" Months of unused complexity and a steep onboarding curve Match the tool to current scale, not hypothetical future scale
No remote cache auth in CI Every runner rebuilds from scratch; caching savings evaporate Configure TURBO_TOKEN / NX_CLOUD_ACCESS_TOKEN as secrets

Frequently Asked Questions

Can I switch from Turborepo to Nx later if my needs grow? Yes, and many teams do. Because Turborepo layers onto your existing workspace without restructuring packages, migrating later mostly means adding Nx config and gradually adopting its conventions. Starting simple with Turborepo and graduating to Nx is a sensible path; the reverse is more disruptive.

Is Nx always slower or faster than Turborepo? Neither is categorically faster. Both cache and skip unchanged work, so on a warm cache they perform comparably. Differences show up at extreme scale, where Nx Cloud's distributed task execution can spread load across agents that Turborepo would require you to shard manually. Measure on your own repo rather than trusting headline numbers, as detailed in the benchmarks page.

Do I need a task runner at all for a small monorepo? Often no. For a handful of packages with fast builds, your package manager's recursive run commands (pnpm -r, with topological ordering and concurrency flags) are simpler and dependency-free. Add a runner when you observe redundant rebuilds, slow CI, or ordering bugs — not preemptively.

Can these tools run non-JavaScript tasks? Turborepo can run any shell command as a cached task with declared inputs/outputs, but it has no language-specific graph intelligence outside JS/TS. Nx is built to be polyglot and, via plugins, understands the dependency graph of other languages — making it the better fit for genuinely mixed-language repos.

What's the relationship between Lerna and Nx today? Lerna is now stewarded by the Nx team and delegates its task running to the Nx engine for caching and parallelism. Existing Lerna users effectively get Nx-powered orchestration, and Lerna's publish/versioning commands remain useful even alongside a modern runner.

Related

Monorepo Architecture & Orchestration