The Real Problem Monorepo Tools Solve
Monorepos consolidate multiple packages, services, and applications into a single repository. Google, Meta, and Microsoft run monorepos with billions of lines of code. But the benefits of shared code, atomic commits across packages, and unified tooling create a new problem: build times that grow linearly with repository size.
Without build orchestration, adding your 50th package means your CI pipeline runs 50 builds, 50 test suites, and 50 lint passes on every pull request regardless of which package actually changed. This is where monorepo tooling becomes essential.
Three tools dominate the space: Turborepo (acquired by Vercel in 2021), Nx (built by Nrwl, now part of the Nx Company), and Bazel (open-sourced by Google). Each makes fundamentally different tradeoffs between simplicity, power, and operational overhead.
Turborepo: Speed Through Simplicity
Turborepo takes the minimal approach. It adds a turbo.json configuration file to your existing npm/pnpm/yarn workspace and immediately provides two capabilities: intelligent task scheduling based on the dependency graph, and content-addressable caching of task outputs.
The configuration is deliberately small. A typical turbo.json defines which tasks depend on other tasks and what directories to cache:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": []
},
"lint": {
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}
The ^build syntax means "run build in all dependency packages first." Turborepo analyzes your package.json dependency graph automatically. No need to declare relationships manually.
Remote caching is the killer feature. Vercel provides a hosted cache, or you can self-host with tools like Docker-based cache servers. When a developer runs turbo build and the inputs haven't changed since the last CI run, Turborepo downloads the cached outputs in seconds instead of rebuilding. Teams report 40-80% CI time reductions within the first week.
Where Turborepo falls short: it has no code generation capabilities, no dependency graph visualization built in, and its analysis is limited to npm package boundaries. If you have a Go service and a TypeScript frontend in the same repo, Turborepo can run both builds but won't understand the dependency between them unless you wire it manually. For more context on CI/CD pipeline design, the integration patterns matter significantly.
Nx: The Full-Featured Middle Ground
Nx positions itself as a build system, not just a task runner. It provides everything Turborepo does, plus code generation, an interactive dependency graph explorer, affected command analysis, and first-class plugins for React, Angular, Node, Go, Rust, and other ecosystems.
Setting up Nx in an existing repository typically takes 1-2 days. The nx init command detects your existing structure and generates a baseline configuration. From there, you configure project targets in project.json files or infer them from package.json scripts:
{
"name": "api",
"targets": {
"build": {
"executor": "@nx/esbuild:esbuild",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts",
"tsConfig": "apps/api/tsconfig.app.json"
}
},
"test": {
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "apps/api/jest.config.ts"
}
}
}
}
Nx's affected analysis is more sophisticated than Turborepo's. When you change a file, Nx traces not just the npm dependency graph but also import-level dependencies. If you modify a shared utility function, Nx determines exactly which applications import that function and runs only their tests. This is particularly valuable in large repositories where testing strategies need to be surgical.
The code generation system (nx generate) scaffolds new libraries, applications, and components with consistent patterns. For teams enforcing architectural standards, this prevents the "every developer structures their package differently" problem that plagues large monorepos. Related patterns show up in modern design patterns where consistency scales.
Nx Cloud provides remote caching and distributed task execution. The distributed execution feature splits a CI pipeline across multiple machines. Instead of one machine running 30 minutes of tests sequentially, Nx Cloud distributes them across 5 machines and finishes in 6 minutes.
Bazel: Hermetic Builds for Massive Scale
Bazel operates on a fundamentally different philosophy. Every build input is explicitly declared. Every build action runs in a sandbox. The same inputs always produce the same outputs, regardless of the machine running the build. This is hermeticity.
The cost of hermeticity is verbosity. Bazel uses BUILD files that describe every source file, dependency, and output explicitly:
load("@rules_go//go:def.bzl", "go_binary", "go_library", "go_test")
go_library(
name = "api_lib",
srcs = [
"main.go",
"handlers.go",
"middleware.go",
],
importpath = "github.com/myorg/monorepo/services/api",
deps = [
"//pkg/auth:auth_lib",
"//pkg/database:database_lib",
"@com_github_gin_gonic_gin//:gin",
"@com_github_jackc_pgx_v5//pgxpool",
],
visibility = ["//visibility:public"],
)
go_binary(
name = "api",
embed = [":api_lib"],
)
go_test(
name = "api_test",
srcs = ["handlers_test.go"],
embed = [":api_lib"],
deps = ["@com_github_stretchr_testify//assert"],
)
Every dependency is listed. The //pkg/auth:auth_lib reference points to another BUILD file in the repository. External dependencies are pinned to exact versions in a WORKSPACE file. Nothing is implicit.
This explicitness pays off at scale. Google's monorepo contains over 2 billion lines of code. Bazel's remote execution distributes builds across thousands of machines. Its caching is byte-level deterministic. If two developers on different continents build the same target with the same inputs, they get bit-identical outputs.
For most teams, Bazel is overkill. The setup cost is measured in weeks, not hours. You need a dedicated build infrastructure team to maintain the BUILD files, manage external dependency rules, and operate the remote execution cluster. But if your repository contains 500+ developers writing Go, Java, TypeScript, and Python services that depend on each other, Bazel is the only tool that scales without degrading. Understanding infrastructure cost management becomes critical when running Bazel's remote execution clusters.
Task Scheduling and Parallelism
All three tools build a directed acyclic graph (DAG) of task dependencies and execute independent tasks in parallel. The differences lie in how they construct that graph.
Turborepo reads package.json workspaces and the dependsOn declarations in turbo.json. The graph is coarse-grained: each node is a package-level task. If package A depends on package B, all of B's build must complete before A's build starts, even if A only imports one file from B.
Nx constructs a finer-grained graph by analyzing actual file imports. It knows that apps/web imports from libs/ui and libs/utils but not from libs/auth. When libs/auth changes, Nx skips rebuilding and retesting apps/web entirely. This import-level analysis can eliminate 30-50% of unnecessary work compared to package-level analysis. For teams managing complex dependency trees, this precision matters.
Bazel's graph operates at the individual file level. Each BUILD target lists its exact source files and dependencies. Bazel can parallelize within a single package, building independent compilation units simultaneously. On a 32-core build machine, Bazel saturates all cores while Turborepo might leave most idle waiting for sequential package builds.
Remote Caching and Execution
Remote caching stores build outputs in a shared location so developers and CI machines avoid redundant work. All three tools support it, but with very different implementations.
Turborepo's cache is the simplest. Each task is hashed based on its inputs (source files, environment variables, dependency outputs). The hash maps to a tar archive of the task's output directories. Cache operations are fast because the unit of caching is large (entire package builds). Vercel hosts the cache service, or you can run a self-hosted server using the open-source API specification. Understanding caching strategies helps optimize hit rates.
Nx Cloud adds distributed task execution on top of remote caching. When a CI pipeline starts, Nx Cloud's coordinator splits the task graph across available agents. Each agent pulls tasks from the queue, checks the cache, executes cache misses, and uploads results. The coordinator reassembles the final output. This turns a 30-minute linear pipeline into a 6-minute distributed one without any CI configuration changes.
Bazel's remote execution is the most powerful and the most complex. Bazel sends individual build actions (compile this file, link this binary) to a remote execution cluster. The cluster runs actions in sandboxed containers, guaranteeing that the build environment matches exactly. Popular remote execution backends include BuildBuddy, EngFlow, and the open-source Buildfarm. Running these clusters is an infrastructure project in itself. Teams exploring this should understand Kubernetes patterns since most remote execution backends run on Kubernetes.
Migration Path and Adoption Strategy
Starting with Turborepo is the lowest-risk approach. Add turbo.json to an existing npm workspace, configure remote caching, and measure CI improvements. This takes a single afternoon and provides immediate value. If Turborepo's limitations become apparent (usually around 30+ packages or polyglot repositories), migrating to Nx is straightforward. Nx can read package.json workspaces and provides a nx init migration command.
Moving from Nx to Bazel is a larger commitment. The conceptual models differ significantly. Nx infers dependencies from code analysis; Bazel requires explicit declaration. Most teams that adopt Bazel do so for new repositories or migrate incrementally, moving one service at a time while keeping Nx or Turborepo for the rest. Understanding Git workflow patterns helps manage the transition across team boundaries.
A pragmatic strategy: use Turborepo until you hit 20-30 packages. Evaluate Nx when you need code generation, polyglot support, or distributed execution. Consider Bazel only when you have a dedicated platform team and your repository's scale demands it. The details of GitHub Actions optimization and Terraform state management become relevant as your infrastructure grows alongside the monorepo.
Configuration and Maintenance Overhead
Turborepo requires one file: turbo.json. Package dependencies are read from existing package.json files. There's nothing to maintain beyond updating the Turborepo version. The simplicity is intentional and covers most small-to-medium teams perfectly.
Nx requires nx.json for global configuration and either project.json or package.json targets per project. Plugins add their own configuration. A typical Nx monorepo has 5-15 configuration files that need periodic updates. Nx provides migration generators (nx migrate) that automate most version upgrades, but complex customizations sometimes require manual intervention.
Bazel requires BUILD files in every directory, a WORKSPACE or MODULE.bazel file for external dependencies, and .bzl files for custom rules. A 50-package monorepo might have 200+ BUILD files. Many teams write BUILD file generators to reduce maintenance burden. Google internally uses a tool called Gazelle for Go that auto-generates BUILD files from source imports. This overhead is Bazel's most common criticism and the primary reason teams choose not to adopt it.
Build system maintenance relates directly to technical debt management. The configuration overhead of each tool maps directly to ongoing maintenance cost. Teams should factor this into their total cost of ownership calculation, not just the initial setup time. Related concepts in developer experience metrics help quantify the impact of build system choices on engineering productivity.
Making the Decision
The decision tree is simpler than the feature comparison suggests:
- Under 20 packages, all JavaScript/TypeScript: Turborepo. You'll be up and running in an afternoon.
- 20-100 packages, multiple frameworks, need code generation: Nx. The 1-2 day setup cost pays for itself within the first sprint.
- 100+ packages, multiple languages, need hermetic builds: Bazel. Commit to weeks of setup and a permanent maintenance investment.
- Uncertain: Start with Turborepo. The migration path to Nx is well-documented, and you'll learn what features you actually need by hitting Turborepo's limits naturally.
The worst outcome is spending two weeks setting up Bazel for a 10-package TypeScript monorepo. The second worst is running Turborepo in a 200-package polyglot repository and watching CI times slowly degrade. Match the tool to your actual scale, not the scale you hope to reach. You can always migrate when the pain justifies it. For more on making pragmatic architectural decisions, consider the organizational context alongside the technical requirements.