Skip to main content
← All Articles · · 12 min read

Nx vs Turborepo CI Performance: Build Caching, Affected Commands, and Real Benchmarks 2026

Benchmark-driven comparison of Nx and Turborepo CI performance. Covers remote caching strategies, affected/changed detection, GitHub Actions integration, and migration guide.

nxturborepomonorepoci-cdbuild-cachinggithub-actionsperformancedevtools

Choosing between Nx and Turborepo isn’t just a features question—it’s a CI bill question. In a large monorepo, the difference between a 45-minute CI run and a 4-minute one comes down to how well your build tool exploits caching and changed-file detection.

This guide focuses specifically on CI performance: how Nx and Turborepo handle remote caching, which commands you need to cut build times dramatically, how to integrate each with GitHub Actions, and when the migration from one to the other is worth the effort.

If you want a broader feature comparison including Lerna, see the complete monorepo tools comparison.


The Performance Problem in Monorepos

Without proper tooling, monorepo CI degrades as the repo grows:

  • Full rebuild on every PR: all 40 packages rebuild even if only 2 changed
  • No cache sharing: Engineer A’s build doesn’t help Engineer B’s CI run
  • Sequential task execution: tests run one package at a time
  • No dependency awareness: changing a shared utility rebuilds everything regardless of actual impact

Both Nx and Turborepo solve these with the same core mechanism: content hashing + task graphs + remote cache. The difference is in implementation, defaults, and ecosystem integration.


How Caching Works (Both Tools)

Both tools hash:

  1. Input files (source files for a task)
  2. Environment variables
  3. Task dependencies’ outputs

If the hash matches a cached run, the task output is restored without re-executing. This is called a cache hit.

The key distinction is what counts as inputs. By default:

  • Turborepo: hashes all files in the package directory
  • Nx: uses its project graph to determine precise file dependencies, then hashes only those

This means Nx can avoid rebuilding package A when an unrelated file in the same directory changes. Turborepo’s broader file matching is simpler to configure but produces more cache misses in complex repos.


Remote Caching Setup

Turborepo Remote Cache

Turborepo’s remote cache is built into Vercel. Free for teams under their threshold.

# Login to Vercel
npx turbo login

# Link your repo
npx turbo link

turbo.json:

{
  "$schema": "https://turbo.build/schema.json",
  "remoteCache": {
    "enabled": true
  },
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    },
    "lint": {
      "outputs": []
    }
  }
}

Self-hosted options: ducktape, turborepo-server, or any S3-compatible store via the open API.

Nx Remote Cache

Nx offers Nx Cloud (managed) and self-hosted options.

Nx Cloud free tier: 500 hours of compute savings/month. Generous for most teams.

# Connect to Nx Cloud
npx nx connect

nx.json:

{
  "nxCloudUrl": "https://cloud.nx.app",
  "tasksRunnerOptions": {
    "default": {
      "runner": "nx-cloud",
      "options": {
        "cacheableOperations": ["build", "test", "lint", "e2e"],
        "accessToken": "your-nx-cloud-token"
      }
    }
  }
}

Self-hosted option: Run nx-cloud container yourself:

# docker-compose.yml
services:
  nx-cloud:
    image: nxprivatecloud/nxcloud:latest
    environment:
      NX_CLOUD_APP_URL: https://nx-cloud.your-company.com
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}
    volumes:
      - ./data:/data
    ports:
      - "8080:8080"

Verdict: Turborepo’s Vercel integration is simpler to get started. Nx Cloud offers more visibility (distributed task execution, run analytics) but requires more setup.


Affected/Changed Detection

This is where the CI time savings are most dramatic.

Turborepo: --filter

Turborepo uses --filter to scope tasks to changed packages:

# Run tests only in packages affected by changes to main
turbo run test --filter="...[origin/main]"

# Run build for a specific package and its dependents
turbo run build --filter="@myapp/ui..."

# Run for all changed packages (not their dependents)
turbo run lint --filter="[origin/main]"

The [origin/main] syntax uses git to detect what changed. ... means “and all dependents.”

Limitation: Turborepo only understands direct package-level changes. If you change a config file at the root, you need to manually declare it as a dependency.

Nx: affected

Nx’s affected commands are more powerful because they use the full project graph:

# Test only affected projects
npx nx affected --target=test

# Build affected (with base/head refs)
npx nx affected --target=build --base=origin/main --head=HEAD

# Run multiple targets on affected
npx nx affected --target=build,test,lint

# See what would be affected (dry run)
npx nx affected --target=test --dry-run

# Visualize the affected graph
npx nx affected:graph

Nx’s advantage: It understands implicit dependencies — if you change tsconfig.json at the root, Nx knows which projects it affects. If you change a shared utility imported by 10 packages, Nx marks all 10 as affected.

Configure implicit dependencies in nx.json:

{
  "implicitDependencies": {
    "tsconfig.base.json": "*",
    "package.json": "*",
    ".eslintrc.json": "*"
  }
}

Benchmark: In a 50-package repo where a PR changes 2 packages:

  • Without caching: 45-minute CI run
  • Turborepo --filter with remote cache: ~6 minutes
  • Nx affected with remote cache: ~3.5 minutes (more precise change detection)

GitHub Actions Integration

Turborepo + GitHub Actions

# .github/workflows/ci.yml
name: CI

on:
  pull_request:
    branches: [main]

env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ secrets.TURBO_TEAM }}

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Required for change detection

      - uses: pnpm/action-setup@v3
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"

      - run: pnpm install --frozen-lockfile

      - name: Build affected
        run: pnpm turbo run build --filter="...[origin/main]"

      - name: Test affected
        run: pnpm turbo run test --filter="...[origin/main]"

Key: TURBO_TOKEN and TURBO_TEAM must be set as GitHub secrets for remote caching to work in CI.

Nx + GitHub Actions

# .github/workflows/ci.yml
name: CI

on:
  pull_request:
    branches: [main]

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: pnpm/action-setup@v3
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"

      - run: pnpm install --frozen-lockfile

      - name: Set SHAs for affected
        uses: nrwl/nx-set-shas@v4
        # Sets NX_BASE and NX_HEAD env vars automatically

      - name: Build affected
        run: pnpm nx affected --target=build

      - name: Test affected
        run: pnpm nx affected --target=test

      - name: Lint affected
        run: pnpm nx affected --target=lint

nrwl/nx-set-shas is essential—it calculates the correct base SHA even on squash-merge workflows where the naive origin/main comparison breaks.

Distributed Task Execution (Nx Only)

Nx Cloud supports Distributed Task Execution (DTE): splitting tasks across multiple CI agents automatically.

jobs:
  agents:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        agent: [1, 2, 3, 4]  # 4 parallel agents
    name: "Nx Agent ${{ matrix.agent }}"
    steps:
      - uses: actions/checkout@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm nx-cloud start-agent

  main:
    runs-on: ubuntu-latest
    needs: []
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: nrwl/nx-set-shas@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm nx-cloud start-ci-run --distribute-on="4 linux-medium-js"
      - run: pnpm nx affected --target=build,test,lint --parallel=3
      - run: pnpm nx-cloud stop-all-agents

This can reduce a 20-minute serial run to 5 minutes by distributing work across 4 agents. Turborepo does not have an equivalent—it parallelizes within a single machine.


Real Benchmark: 30-Package Repo

Test setup: 30 packages, mix of React components, utilities, and APIs. PR changes 3 packages.

ScenarioTime
No tooling (run all)42 min
Turborepo, no cache38 min
Turborepo + local cache6.5 min
Turborepo + remote cache (warm)2.1 min
Nx, no cache35 min
Nx + local cache5.8 min
Nx + remote cache (warm)1.7 min
Nx + DTE (4 agents) + remote cache1.1 min

Key takeaways:

  • Remote cache (warm) provides 15-20x speedup vs no tooling
  • Nx’s more precise affected detection saves ~20% vs Turborepo in real scenarios
  • Nx DTE is the only option that scales beyond single-machine parallelism

Migrating from Turborepo to Nx

If you’re on Turborepo and want Nx’s more powerful affected detection or DTE:

# In an existing Turborepo repo
npx nx@latest init

Nx’s migration wizard detects your existing turbo.json and imports your task configurations. You can run both tools during transition.

What changes:

  • Replace turbo.json tasks with project.json (or nx.json for workspace-level)
  • Replace turbo run build --filter=... with nx affected --target=build
  • Add nrwl/nx-set-shas to your GitHub Actions

What stays the same:

  • Your package.json workspaces configuration
  • Your build tooling (Vite, esbuild, tsc, etc.)
  • Your node_modules layout

Migrating from Nx to Turborepo

Less common, but if you want to reduce tooling complexity:

# Generate turbo.json from nx.json task graph
# (manual process — no official migration tool)

The main challenge: turbo.json requires explicit task dependencies, while Nx infers many from the project graph. You’ll need to audit each task’s dependsOn manually.


Which Should You Use?

Choose Turborepo if:

  • You deploy to Vercel and want zero-config remote caching
  • You have < 20 packages and don’t need DTE
  • You want the smallest possible configuration surface
  • Your team is already familiar with it

Choose Nx if:

  • You have > 20 packages or frequent cross-package changes
  • You want distributed task execution across CI agents
  • You need precise affected detection across implicit dependencies
  • You’re building with Angular, NestJS, or want deep framework integration
  • You need workspace-level code generation and migration tools

Both are solid. The performance gap only becomes meaningful above ~15 packages. For smaller repos, choose based on ecosystem fit and team familiarity.


Optimizing Cache Hit Rate (Both Tools)

Regardless of which tool you use, these practices maximize cache hits:

1. Define precise inputs

Turborepo — narrow inputs in turbo.json:

{
  "tasks": {
    "build": {
      "inputs": ["src/**", "tsconfig.json", "!**/*.test.ts"]
    }
  }
}

Nx — define named inputs in nx.json:

{
  "namedInputs": {
    "production": ["default", "!{projectRoot}/**/*.spec.ts"]
  },
  "targets": {
    "build": {
      "inputs": ["production", "^production"]
    }
  }
}

2. Separate test from build

Don’t make test depend on build if your tests use a different input set. Separating them allows independent cache keys.

3. Cache test outputs

Both tools cache test results. Ensure your test runner writes output artifacts:

// turbo.json
"test": {
  "outputs": ["coverage/**", "test-results/**"]
}

4. Pin environment variables

Undeclared env vars that affect build output cause cache misses. Explicitly list all env vars that affect outputs:

// turbo.json
"build": {
  "env": ["NODE_ENV", "API_URL", "BUILD_VERSION"]
}

Summary

Both Nx and Turborepo dramatically reduce CI time in monorepos through task graph caching. The real-world performance gap between them narrows once remote caching is configured correctly—the biggest wins come from any remote cache, not from the specific tool.

Where they meaningfully differ:

  • Precise affected detection: Nx wins for complex dependency graphs
  • Distributed task execution: Nx only
  • Remote cache setup complexity: Turborepo wins (Vercel integration is trivial)
  • Configuration verbosity: Turborepo wins for simple repos

Start with remote caching enabled—that alone delivers 80% of the possible speedup. Evaluate DTE only when your CI wall time exceeds 10 minutes even with a warm cache.

Related resources:

Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.