TL;DR — Nx transforms monorepo builds into a deterministic task graph, enabling incremental builds, distributed caching, and parallel execution. By configuring targets correctly and leveraging remote caching, teams can reduce build times from hours to minutes. This post dives into the architecture, caching strategies, and production patterns that make Nx a powerhouse for high-performance build pipelines.
Monorepos scale engineering velocity, but only if the build system keeps up. As codebases grow, naive npm run build scripts become bottlenecks—slow feedback loops, flaky CI, and developer frustration. Nx, the open-source toolkit for monorepo build orchestration, addresses this by modeling every build task as a node in a computation graph. The result: only what changed gets rebuilt, cached results are shared across machines, and parallelism is automatic.
In this deep dive, we’ll explore how Nx achieves this, with concrete configurations and production patterns you can apply today.
Nx Architecture: The Computation Graph
At the heart of Nx lies the task graph. When you run nx build my-app, Nx doesn’t just execute a script; it:
- Resolves dependencies between projects by analyzing
package.jsonreferences, TypeScript path aliases, and project configuration. - Creates a directed acyclic graph (DAG) of tasks, where each node is a target (e.g.,
build,test,lint) for a specific project. - Determines the minimal set of tasks that need to run based on what files changed since the last successful build.
This graph is deterministic—given the same inputs, the same tasks are always scheduled. That determinism is the foundation for caching and parallelism.
// nx.json — root configuration
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"npmScope": "myorg",
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist"]
},
"test": {
"dependsOn": ["build"]
}
},
"parallel": 4,
"cacheDirectory": ".cache"
}
The targetDefaults block above is critical. dependsOn: ["^build"] means “build all dependencies before building this project,” while outputs: ["dist"] tells Nx where to look for build artifacts to cache.
Building High-Performance Pipelines
Performance in Nx isn’t magic—it’s about configuring the task graph to minimize redundant work.
1. Define Granular Targets
Avoid monolithic scripts. Break build into compile, bundle, and typecheck. This allows Nx to cache each phase independently. For example, if only types change, bundle can skip re-bundling because its inputs (compiled JS) haven’t changed.
# projects/my-app/project.json
{
"root": "apps/my-app",
"targets": {
"compile": {
"executor": "@nrwl/js:tsc",
"options": {
"outputPath": "dist/apps/my-app"
}
},
"bundle": {
"executor": "@nrwl/webpack:webpack",
"dependsOn": ["compile"],
"options": {
"outputPath": "dist/apps/my-app"
}
}
}
}
2. Leverage Input Tracking
Nx tracks file changes at the hash level. By default, it watches all files in the project, but you can scope inputs to avoid false positives:
// nx.json
{
"targetDefaults": {
"build": {
"inputs": ["{projectRoot}/**/*", "!{projectRoot}/**/*.spec.ts"]
}
}
}
Here, we exclude test files from build inputs, so changing a test doesn’t trigger a full rebuild.
3. Parallelize with Care
Nx runs tasks in parallel up to parallel limit (set in nx.json). However, dependency order is always respected—build for project B won’t run before build for project A if B depends on A. Use nx run-many to orchestrate multiple projects:
nx run-many --target=build --parallel=4 --all
Caching Strategies: The Performance Multiplier
Caching is where Nx shines. When a task completes, Nx stores its outputs keyed by input hashes. On subsequent runs, if inputs match, the task is skipped and outputs are restored.
Local Caching
By default, Nx caches to .cache/ in the workspace root. This speeds up repeated runs on the same machine. For example, after a clean CI run, a developer pulling the repo will see instant build hits for unchanged projects.
Remote Caching
For distributed teams, remote caching is a game-changer. Nx supports any S3-compatible storage, GitHub Actions cache, or even a custom API. Configure it once in nx.json:
{
"remoteCache": {
"type": "s3",
"bucket": "myorg-nx-cache",
"region": "us-east-1"
}
}
In CI, you can push cache hits on main and pull on feature branches. This reduces CI time by 60–80% in large repos, as reported by Nx documentation.
Cache Invalidation
Caches can go stale if you change compiler versions or global config. Use nx reset to clear the cache when needed. For finer control, mark tasks as cacheable: false when outputs are non-deterministic (e.g., tasks that fetch external data).
Patterns in Production
Let’s look at how real teams use Nx at scale.
1. The “Build Once, Test Everywhere” Pattern
In a monorepo with shared libraries, you don’t want to rebuild lib/utils for every consumer. Instead, build it once, publish to a local registry (or use nx watch), and let downstream projects consume the artifact.
# Build all libraries, then apps
nx build lib/utils lib/components --parallel
nx build app-web app-mobile --dependsOn="^build"
2. CI Pipeline Optimization
Avoid running all tests on every PR. Use Nx’s affected command:
nx affected --target=lint --target=test --target=build
This only processes projects changed in the PR (and their dependents). Pair this with remote caching, and your CI bill drops dramatically.
3. Incremental Type Checking
Combine tsc with --project flags to typecheck only affected projects. Tools like @nrwl/js:tsc executor handle this automatically when used as a target.
Advanced Configuration: Custom Executors
When off-the-shelf executors don’t fit, write your own. An executor is just a Node script that receives options and returns a stream of task results. For example, a custom Docker build executor:
// executors/docker/build/index.js
const { execSync } = require('child_process');
exports.default = (options) => {
const cmd = `docker build -t ${options.tag} ${options.context}`;
execSync(cmd, { stdio: 'inherit' });
return { success: true };
};
Register it in executors.json and use it like any other target.
Key Takeaways
- Nx models builds as a DAG, enabling incremental, parallel, and cached execution.
- Configure
targetDefaultsto define dependency relationships and outputs globally. - Remote caching is essential for team-wide performance; S3 or GitHub Actions cache are easy starting points.
- Use
nx affectedin CI to limit work to what changed. - Break monolithic scripts into granular targets for finer caching granularity.
- Custom executors let you integrate Nx with any toolchain, from Docker to Webpack.