Why monorepo builds get slow
A single-repository codebase has real advantages - unified versioning, atomic cross-project changes, shared tooling - but the build system has to work harder to stay fast as the graph grows. The root causes fall into three categories:
- Graph size and blast radius. Every project added to the monorepo adds nodes and edges to the task dependency graph. A change to a deeply shared library - a utility package, a shared UI component, a configuration preset - can invalidate dozens or hundreds of downstream tasks. Without an affected-graph calculation, every CI run rebuilds and retests the world.
- CI cold starts. Ephemeral CI runners have an empty local cache on every job invocation. If the build system does not consult a remote cache, it recomputes everything - including tasks whose inputs have not changed since the last run minutes earlier on another machine. The bigger the monorepo, the worse this compounds.
- Non-incremental and poorly keyed workflows. Even teams with caching enabled often see low hit rates because task inputs are over- or under-specified: a single environment variable or timestamp leaks into the hash, busting the cache for all projects on every run.
The good news is that all three are addressable. The bad news is that they require different fixes, and applying the wrong fix to the wrong bottleneck wastes effort. That makes measurement the mandatory first step.
Measurement first: wall time, critical path, and queue time
Before optimizing anything, distinguish between three kinds of time that all contribute to how long a build feels:
| Time type | Definition | Primary fix |
|---|---|---|
| Wall time | End-to-end pipeline duration including queue, setup, and task execution | Diagnose sub-components first |
| Critical-path time | Minimum pipeline duration set by the longest sequential task chain | Parallelism, task splitting, remote cache on the hot path |
| Queue time | Time waiting for a runner before any task executes | More or larger runner pools, faster runner provisioning |
A team that adds more runners to fix a slow build because critical-path task time is the actual bottleneck will see little improvement. A team that tunes cache inputs to fix slow builds caused by queue time will see the same result. Start with the CI provider's own job timeline view to split wall time into its components, then drill into the largest slice before writing a line of configuration.
What to measure per task
Once you have isolated task execution as the bottleneck, measure at the task level: for each task in the graph record the cold run duration (no cache hit), the warm hit rate (fraction of runs served from cache), and the estimated time saved per run (hit rate multiplied by cold duration). This is the data that drives every subsequent decision - which tasks to parallelize, which inputs to trim, and whether the remote cache ROI is worth the subscription cost.
Most modern build tools expose this. Nx prints a read-from-remote-cache or local cache hit line per task and aggregates time saved in the run summary. Turborepo shows FULL TURBO per cached task. Cachely's dashboard makes the per-workspace hit rate and time saved visible over time in the build insights panel.
Deterministic builds and hermeticity
A deterministic build always produces the same outputs from the same inputs. This is the prerequisite for a trustworthy cache: if the same task inputs can produce different outputs on different machines or at different times, a cache hit from a previous run may return something subtly different from what a fresh build would have produced.
The common sources of non-determinism in monorepo builds are:
- Timestamps embedded in outputs. Compiled binaries, generated documentation, and zip archives sometimes record the build time. The resulting hash changes on every run even with identical source, so every run is a cache miss.
- Environment variable leakage. A task that reads
CI,BUILD_NUMBER, or a machine-specific variable produces different hashes on CI versus a developer machine, breaking the cross-machine cache entirely. - Undeclared dependencies. If a task reads a file that is not in its declared input set, the cache key will not reflect that file's content. Changing the file does not invalidate the key - the task returns a stale result.
- Floating dependency versions. A package pinned to
^1.2.0can resolve to different patch versions on different machines, producing different outputs from the same lockfile hash.
Hermeticity
Hermeticity is the strongest form of determinism: every input to every task is explicitly declared and the execution environment is isolated so undeclared inputs cannot leak in. Bazel enforces hermeticity with sandboxed actions - each action sees only the files it declares and a controlled toolchain. Nx and Turborepo rely on developer discipline and hash-based keys rather than sandbox enforcement, which is a practical trade-off but means undeclared dependencies can silently cause incorrect cache hits.
For most JavaScript and TypeScript monorepos, full hermeticity is not the right target. Instead, aim for reproducible builds: lock all dependency versions, pin the Node and package manager versions in CI and locally (a .nvmrc read by both), keep task inputs to the files and environment variables that actually affect the output, and test that two clean builds of the same commit produce the same dist artifact.
Incremental builds and affected graphs
An affected graph narrows the set of tasks a run has to execute to only those downstream of code that changed relative to a base branch (usually main). In a fifty-project monorepo, a PR that touches one utility library might affect six downstream projects - the other forty-four are unaffected and can be skipped entirely, or served from cache if they were affected but have already been computed.
Affected execution is the highest-leverage optimization available and requires no cache infrastructure. Enable it first:
# Nx: run only affected projects
npx nx affected -t build test lint
# Turborepo: compare to the base branch
npx turbo run build test lint --affectedThe affected calculation uses the source control diff, so it is cheap: it scans the changed files, maps them to the projects that own them, and walks the dependency graph forward to find all downstream tasks. The result is a minimal subgraph - exactly the work that the branch introduces.
How affected and remote cache complement each other
Affected execution reduces the graph to what must be run. A remote build cache further reduces that graph by replaying tasks that have been computed anywhere before. They solve different problems:
- Affected skips tasks that are provably unchanged. No cache lookup needed - the work simply does not happen.
- Remote cache replays tasks that are in the affected scope but have already been executed with the same inputs on another machine. The work has to happen in the scope, but it was already done somewhere else.
A CI run on a PR branch, for example, executes affected tasks relative to main. Most of those tasks ran on the same commit minutes earlier when a developer pushed locally. Without a remote cache, CI recomputes them all. With a warm remote cache, CI replays most of them in milliseconds and only executes the truly novel work.
Task splitting and parallelism
The affected subgraph still has a critical path - the longest chain of tasks that cannot be parallelized because each depends on the previous. Splitting long-running tasks (for example, distributing test files across multiple runners or running independent project builds in parallel jobs) reduces critical-path time independently of caching. The two approaches stack: use task splitting to shorten the critical path, and use the remote cache to reduce the volume of work that has to run at all.
Cache layers: local, remote, CI, and artifacts
Four distinct caching mechanisms operate in a modern monorepo CI pipeline. They solve different problems and are complementary, not alternatives.
| Layer | What it stores | Scope | ROI |
|---|---|---|---|
| Local task cache | Per-task outputs on the local machine | One machine only | High for reruns on the same machine |
| Remote task cache | Per-task outputs shared across all machines | Team-wide: CI and laptops | Highest - eliminates repeated work everywhere |
| CI dependency cache | Package directories (node_modules, ~/.gradle, pip envs) | CI only | Moderate - speeds installs but not builds |
| Artifact storage | Final build outputs for deployment or downstream consumers | Between jobs and pipelines | Low for build speed; high for deployment speed |
The remote task cache has the highest ROI because CI runners start with an empty local cache on every run. A runner that builds a project whose inputs have not changed since any other runner built it - on this branch, on main, on a colleague's laptop - gets a cache hit. Without the remote layer, that prior work is silently repeated.
CI dependency cache (GitHub Actions' actions/cache or the equivalent) is worthwhile but addresses a different bottleneck - the package install phase. It does not skip compilation, tests, or linting. For a deeper look at how the layers interact, see the GitHub Actions cache guide.
Setting up the remote cache
Connecting Nx to a remote cache requires two environment variables - the server URL and a token. Set a read-write token on protected branches and a read-only token on pull-request and fork builds to prevent untrusted code from poisoning the shared cache:
# Protected branch (read-write)
NX_SELF_HOSTED_REMOTE_CACHE_SERVER=https://remote.cachely.dev
NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN=${{ secrets.CACHELY_RW_TOKEN }}
# Pull-request / fork builds (read-only)
NX_SELF_HOSTED_REMOTE_CACHE_SERVER=https://remote.cachely.dev
NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN=${{ secrets.CACHELY_RO_TOKEN }}Turborepo uses three variables. The TURBO_TEAM value is arbitrary - the workspace is resolved from the token:
TURBO_API=https://remote.cachely.dev
TURBO_TOKEN=${{ secrets.CACHELY_RW_TOKEN }}
TURBO_TEAM=my-orgGradle uses HTTP Basic auth with an empty username and the token as the password. Add to settings.gradle.kts:
buildCache {
remote<HttpBuildCache> {
url = uri("https://remote.cachely.dev")
credentials {
username = ""
password = System.getenv("CACHELY_TOKEN") ?: ""
}
isPush = System.getenv("CI") != null
}
}Hit-rate economics and ROI
A common mistake is treating hit rate as the primary success metric. Hit rate tells you how often the cache is consulted and returns a hit, but says nothing about the value of those hits. The metric that matters is time saved, which requires knowing how long the cached tasks would have taken to run cold.
How to calculate cache ROI
For each task type tracked in your build system:
- p50 cold duration - the median execution time on a cache miss. Use this rather than the mean to avoid skew from outliers.
- hit count - the number of times that task type was served from cache in the period.
- time saved = cold duration × hit count - the total execution time avoided.
Multiply time saved by your CI cost per minute (roughly $0.008 per minute for a standard 2-core GitHub-hosted Linux runner as of mid-2025) to get a direct dollar figure. Multiply by the average developer burdened hourly rate divided by 60 to get the engineering cost of the waiting time. A CI savings calculator can help with the arithmetic.
If the cache is connected but reuse remains low, work through the build cache hit-rate diagnosis before adding more runners or changing providers.
Typical outcomes with a warm cache
Teams with well-tuned inputs and a warm remote cache routinely report:
- 30-70% shorter CI wall time on pull-request pipelines
- Near-instant re-runs of unchanged tasks on developer machines
- First-build speeds for new hires and clean CI agents indistinguishable from a warm machine
The largest gains come on the builds that would otherwise be coldest - CI runners on pull-request pipelines, onboarding builds on new machines, and hotfixes where time pressure is highest.
CI anti-patterns that kill build performance
Global cache invalidation
A monorepo configuration file (root package.json, nx.json, turbo.json, a shared ESLint config) is often included as a global input to every task. When that file changes - even for an unrelated field - every task in the entire graph is invalidated and CI runs a full cold build. The fix is to scope global inputs to the fields that actually affect task outputs, not the whole file:
// nx.json - scope inputs to relevant sections
"namedInputs": {
"default": [
"{projectRoot}/**/*",
"sharedGlobals"
],
"sharedGlobals": [
{ "env": "NODE_VERSION" },
{ "fileset": "{workspaceRoot}/nx.json" }
]
}Environment drift between CI and local
If CI uses Node 20 and developers use Node 22 (or vice versa), the Node version is effectively an undeclared input - the same source produces different byte-for-byte outputs. The cache keys never match, so every CI run is cold even against a warm local cache. Pin the Node version in a .nvmrc and reference the same file in CI:
# .github/workflows/ci.yml
- uses: actions/setup-node@v7
with:
node-version-file: .nvmrcUnstable outputs
Build outputs that embed timestamps, process IDs, random seeds, or absolute paths produce different bytes on every run even with identical inputs. The cache key is computed from inputs, but cache correctness requires that identical inputs produce identical outputs - if they do not, a cache hit may return a byte-for-byte different artifact from a fresh build. Audit your build for:
- Timestamp injection. Many bundlers embed a build timestamp by default. Disable it or fix it to the source-control commit timestamp.
- Non-deterministic test ordering. Test runners that randomize execution order and embed it in coverage reports break the output hash. Write test-order-independent code and use a fixed seed or omit ordering from coverage output.
- Absolute path embedding. Generated source maps, documentation, or type declaration files that include the absolute workspace path on disk produce different bytes on CI (e.g.
/home/runner/work/...) versus a developer machine. Use workspace-relative paths in tool configuration.
Overly broad cache keys
The opposite of environment drift is an overly narrow cache key - one that includes too many inputs and busts too often. A task that hashes all files in the repository root, including README.md, documentation, and changelog files, will miss on every doc update even though the task output is unchanged. Scope inputs to the minimum set that genuinely affects the output.
For a deeper look at input tuning, see Tuning Nx cache inputs and outputs.
Build tools compared: Nx, Turborepo, Bazel, Gradle
Nx
Nx is the most widely adopted build system for JavaScript and TypeScript monorepos. It computes a dependency graph from package.json workspaces or explicit project configuration, infers task inputs and outputs, runs tasks in parallel respecting dependencies, and caches results both locally and - via the self-hosted remote cache API - remotely. The affected-graph calculation (nx affected) is first-class. Nx also supports distributed task execution for running the task graph across many agents in parallel, which further cuts critical-path time for large monorepos.
Turborepo
Turborepo focuses narrowly on fast JavaScript and TypeScript task running: a pipeline definition in turbo.json, per-task dependency edges, local and remote caching via the Vercel Remote Cache protocol, and an affected filter. It has a shallower learning curve than Nx and integrates naturally into npm or pnpm workspaces. For remote caching outside Vercel, any server implementing the Turborepo remote cache protocol works, including Cachely.
Bazel
Bazel enforces hermeticity via sandboxed actions and explicit dependency declarations. Every action sees only the files it declares, the environment is controlled, and outputs are content-addressed. This makes Bazel caches highly trustworthy - a hit is guaranteed to be equivalent to a fresh build. The trade-off is a substantial investment in BUILD files, toolchain configuration, and developer onboarding. Bazel is the right choice for large polyglot monorepos (Go, Java, C++, Python, plus JavaScript) and organizations with strict auditability requirements.
Gradle
Gradle's build cache (both local and remote via the HTTP Build Cache protocol) applies to JVM, Android, and multi-language projects. Task caching is opt-in per task type, which means the hit rate depends on how thoroughly tasks declare their inputs and outputs. Teams with mature Gradle builds and well-annotated tasks see strong hit rates; teams with legacy, side-effect-heavy tasks see lower ones. Cachely implements the Gradle HTTP Build Cache protocol natively.
Choosing the right tool
For pure JavaScript and TypeScript monorepos: start with Nx or Turborepo. Both deliver most of the build performance benefits with far less setup than Bazel. Choose Nx if you want a richer feature set (project graph visualization, code generation, distributed execution, plugin ecosystem); choose Turborepo if you want a minimal, low-configuration task runner on top of an existing npm workspace.
For polyglot monorepos or organizations that need hermetic guarantees: evaluate Bazel. The operational investment is real but so are the reliability and scalability benefits at scale.
For JVM and Android projects already on Gradle: enable the Gradle build cache before adding any other layer. The protocol is stable, the tooling is mature, and the ROI is immediate on teams with correctly annotated tasks.
Practical playbook and checklist
Apply this checklist in order. Each step is independently valuable; the full stack gives the maximum result.
Phase 1: measure baseline (one day)
- Record p50 and p95 CI wall time for the last 30 days from your CI provider's analytics.
- Split wall time into: queue time, dependency install time, task execution time.
- Identify the three slowest task types by total execution time across all runs.
Phase 2: affected graph (one day)
- Enable
nx affectedorturbo run --affectedon pull-request pipelines. - Confirm the change reduces the task count on a typical PR by at least 50%.
Phase 3: remote cache (half day)
- Create a workspace and two tokens (read-write for protected branches, read-only for PRs) in Cachely or your preferred remote cache provider.
- Set the environment variables in CI and locally.
- Run the same build twice; confirm the second run reports cache hits.
Phase 4: input tuning (one to three days)
- Audit each slow task's declared inputs. Remove files that do not affect the output (docs, changelogs, unrelated config sections).
- Pin the Node version in
.nvmrcand reference it in CI. - Lock all package manager versions to prevent resolver drift.
- Check build outputs for embedded timestamps, absolute paths, or random seeds.
Phase 5: parallelism and task splitting (ongoing)
- Profile the critical path. Identify the longest sequential chain of tasks.
- Split large test suites across multiple runners or use distributed task execution where the build tool supports it.
- Add a CI dependency cache (
actions/cache) for the package manager install step if install time exceeds 60 seconds.
Phase 6: monitor and iterate
- Track hit rate and time saved per week. A declining hit rate after an improvement usually means a new undeclared input slipped in.
- Review the build insights dashboard monthly for new optimization opportunities.
Cachely provides managed remote caching for Nx, Turborepo, and Gradle - the infrastructure, token security, ROI reporting, and build insights without running a server. The free plan covers individual developers and small teams. For Nx teams considering the options, see the comparison pages.
Start freeRelated guides
- What is a remote cache?The pillar guide to content-hashed keys, local vs remote caching, and safety.
- Improve cache hit rateDiagnose over-keying, env drift, unstable outputs, and missing outputs.
- GitHub Actions cache guideactions/cache vs artifacts vs a task-level remote build cache in CI.
- Nx vs Turborepo cachingProtocol setup, security, and performance tradeoffs for choosing between them.
- CI savings calculatorEstimate the build minutes, engineer hours, and CI cost remote caching saves.