What slows monorepo CI down, and how to measure it

If you are a backend or platform engineer whose CI pipeline gets slower every quarter while the change in a typical pull request stays the same size, the cause is usually structural. Every change pays for the whole repository: a full checkout, a full dependency install, and a build and test of every target, whether or not the change could affect them.

Before optimizing, split a pipeline run into phases and time each one. Most CI systems show per-step durations in the run log, which is enough to start.

Phase What makes it grow with the repo Typical lever
Queue More jobs competing for runners Fewer or shorter jobs
Checkout/setup Repository history and size Shallow or partial clone
Dependency install Union of all packages' dependencies Cache keyed on lockfiles
Build Number of targets compiled Build only affected targets
Test Number of suites executed Test only affected targets
Artifact upload Outputs from every target Upload only what changed

Measure the critical path: the longest chain of dependent jobs, which sets how long a developer waits. Total compute minutes affect the bill, not the wait. Adding parallel jobs can increase compute while leaving the critical path unchanged.

The approach in this guide is to do less work per change instead of buying bigger runners. Larger machines speed up each step, but the work still grows with the repository. This pays off when the repo has many independent targets and a full run is slow. In a small repo where a full run already finishes quickly, dependency-graph tooling adds complexity and saves little time.

How affected builds work: the dependency graph

Affected-build detection answers one question: given the files this change touched, which targets could behave differently? The answer can only be as good as the dependency graph behind it. If a library imports a shared module that the graph doesn't record, a change to that module skips the library's tests and the pipeline reports green on code it never exercised. A wrong graph is worse than no graph. A full run is slow, but a pruned run on a bad graph is wrong without telling you.

There are three common ways to make the graph explicit:

  • Package manifests. Each package declares its dependencies on other workspace packages. This costs little to adopt because the manifests already exist, but the edges are only package-level, and anything a manifest doesn't cover, such as code generation or shared config, goes unrecorded.
  • Build-tool target declarations. Each target lists its sources and dependencies, and the build tool enforces them. The graph is precise and hard to drift from, but the migration cost is real.
  • A maintained ownership map. A file maps path patterns to targets and lists dependencies by hand. It works for any stack, but it decays unless something checks it.

Whichever source you choose, the flow is the same. First, diff the branch against its merge base to get the changed files. Map each file to its owning target. Walk the graph in reverse to collect every target that depends on those, directly or transitively. Emit that set as the job matrix. Finally, a single gate job waits on the matrix, so branch protection has one stable check to require.

 +-----------------------------+
 | 1. git diff vs merge base   |
 +--------------+--------------+
                | changed files
                v
 +-----------------------------+     +--------------------------------+
 | 2. File -> owning target    |<----+        Dependency graph        |
 +--------------+--------------+     | Built from one of:             |
                | owning targets     |  - package manifests           |
                v                    |  - build-tool targets          |
 +-----------------------------+     |  - maintained ownership map    |
 | 3. Reverse walk: direct and |<----+ A missing edge skips tests     |
 |    transitive dependents    |     | and still reports green.       |
 +--------------+--------------+     +--------------------------------+
                | affected targets
                v
 +-----------------------------+
 | 4. Job matrix               |
 |  +------+ +------+ +------+ |
 |  |lib-a | |svc-b | |app-c | |
 |  +--+---+ +--+---+ +--+---+ |
 +-----+--------+--------+-----+
       |        |        |
       +--------+--------+
                | all must pass
                v
 +-----------------------------+
 | 5. Gate job: the one check  |
 | branch protection requires  |
 +-----------------------------+

The dependency graph turns the changed files from a merge-base diff into owning targets and then into their transitive dependents, which become the job matrix behind a single gate check.

Implementing affected builds and global-input rules

Diff against the merge base

Compute changed files with git merge-base between the PR head and the target branch, then git diff --name-only from that commit to HEAD. Diffing against HEAD~1 only sees the last commit. A PR with five commits, or one that merged the target branch in midway, would silently skip targets touched earlier. The merge base is the point where the branch diverged, so the diff covers exactly what the PR introduces.

CI checkouts are often shallow by default, and a shallow clone may not contain the merge base at all. When that happens, git merge-base fails or returns nothing, and a script that treats "no changes" as success will run nothing. Fetch enough history for the merge base to resolve, either full history or a deepening fetch of the target branch. If it still cannot be found, fail loudly or fall back to a full run.

Map, expand, and apply global inputs

Map each changed path to the target that owns it, then expand through reverse dependencies as described above. Some files have no single owner because everything depends on them: root build configuration, lockfiles, shared CI scripts, toolchain version pins, and the build definitions themselves. A change to any of these should trigger a full run, or a broad one.

Keep this list in version control, assign it an owner, and review it whenever someone adds a new shared file. If a global input is missing from the list, changes to it pass CI without testing anything they affect. The opposite problem also costs you. Each pattern you add as a precaution widens the set of changes that force a full run, and an overly cautious list eventually brings back the slow pipeline you set out to fix.

#!/usr/bin/env bash
# Decide between an affected-only run and a full run for a PR.
set -euo pipefail

TARGET="${TARGET_BRANCH:-main}"
# Version-controlled, owned list of global-input regexes, one per line.
# Example lines: ^package-lock\.json$  ^\.ci/  ^\.tool-versions$  ^BUILD\.root$
# No blank lines: an empty pattern matches every path.
GLOBAL_INPUTS=".ci/global-inputs.txt"

full_run() { echo "MODE=full ($1)"; exit 0; }

# Make sure the target branch exists locally as a remote-tracking ref.
git fetch --no-tags origin "+refs/heads/$TARGET:refs/remotes/origin/$TARGET"

# Diff against the merge base, not HEAD~1, so every commit in the PR counts.
if ! BASE=$(git merge-base "origin/$TARGET" HEAD 2>/dev/null); then
  # Shallow checkouts often lack the fork point; fetch full history and retry.
  if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
    git fetch --no-tags --unshallow origin
  fi
  # Never treat a missing merge base as "nothing changed".
  BASE=$(git merge-base "origin/$TARGET" HEAD 2>/dev/null) \
    || full_run "merge base not found"
fi

CHANGED=$(git diff --name-only "$BASE" HEAD)
[ -n "$CHANGED" ] || { echo "MODE=none"; exit 0; }

# Any change to a global input means no single target owns it.
if printf '%s\n' "$CHANGED" | grep -qE -f "$GLOBAL_INPUTS"; then
  full_run "global input changed"
fi

# Hand off to the build tool, which maps paths to owning targets and
# expands reverse dependencies.
printf '%s\n' "$CHANGED" > changed-files.txt
echo "MODE=affected ($(wc -l < changed-files.txt) files)"

A CI script that diffs against the PR's merge base and fetches full history when a shallow clone hides it. It falls back to a full run when the merge base cannot be found or a path matches the maintained global-inputs list. Otherwise it passes the changed files on for target mapping.

Caching with correct keys

Caching removes work that affected builds can't skip. A cache is only as safe as its key, though. Two kinds are worth separating. A dependency cache should be keyed on a hash of the lockfile, plus the OS, architecture and toolchain version. A cache built with one compiler or runtime can restore cleanly and then fail at link time on another. A build-output cache should be keyed per package on a hash of that package's sources and the outputs of its dependencies. That way, a change ripples only as far as the dependency graph says it should.

Restore-key fallback is a prefix match. If the exact key misses, the runner restores the most recent entry whose key starts with the fallback prefix, and the tool updates it incrementally. This works well for package-manager stores, where a near-miss saves most of the download. It is dangerous for build outputs, because the build has to detect every stale file correctly. If you can't trust that, use exact keys only.

Caches can also cost more than they save. Restoring a large archive means downloading and decompressing it. If your timings show that this takes longer than a fresh install from a nearby registry mirror, drop the cache.

Failure modes to watch for:

  • Stale or poisoned entries: a pull-request branch writes a bad artifact that main later restores. Limit which branches can write to shared caches.
  • Undeclared inputs: the build reads environment variables, generated files or the system clock without those being part of the key. Two different builds then share one key and return wrong results.
  • Storage limits and eviction: once a cache fills up, older entries are evicted, so cold runs show up at random. Track cache hit rate as a metric so these misses show up as a trend instead of looking like random slow runs.
name: ci
on: [push, pull_request]
env:
  NODE_VERSION: '20.11.1'   # toolchain version is part of every key
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
      # Dependency cache: keyed on OS, arch, toolchain and lockfile hash.
      # A prefix fallback is acceptable here because npm verifies each tarball
      # by integrity hash, so a near-miss only saves downloads.
      - uses: actions/cache@v4
        with:
          path: ~/.npm
          key: deps-${{ runner.os }}-${{ runner.arch }}-node${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            deps-${{ runner.os }}-${{ runner.arch }}-node${{ env.NODE_VERSION }}-
      - run: npm ci
      # Build-output cache for one package: exact key only, no restore-keys.
      # Hashing the shared package's sources stands in for hashing its outputs.
      - id: api-build
        uses: actions/cache/restore@v4
        with:
          path: packages/api/dist
          key: build-api-${{ runner.os }}-${{ runner.arch }}-node${{ env.NODE_VERSION }}-${{ hashFiles('packages/api/src/**', 'packages/api/package.json', 'packages/shared/src/**', 'package-lock.json') }}
      - if: steps.api-build.outputs.cache-hit != 'true'
        run: npm run build --workspace=packages/api
      # Only main writes build outputs, so a pull-request run cannot
      # store an artifact that main later restores.
      - if: github.ref == 'refs/heads/main' && steps.api-build.outputs.cache-hit != 'true'
        uses: actions/cache/save@v4
        with:
          path: packages/api/dist
          key: ${{ steps.api-build.outputs.cache-primary-key }}

In this GitHub Actions example, the dependency cache is keyed on OS, architecture, toolchain version and lockfile hash and allows a prefix fallback, while the per-package build cache uses only an exact content-hash key and only main can write to it.

Parallelism, fixed per-job cost, and the required-check gate

Fan out from the graph, not from a static list

Have a first "plan" job compute the affected targets and emit them as JSON. Downstream jobs consume that output as a dynamically generated matrix, so a docs-only change spawns zero test jobs, while a change to a shared library spawns many. For large suites, shard by historical duration rather than file count. One file can hold a two-minute integration test and another fifty fast unit tests. Store per-test timings from previous runs and assign the longest tests first to the least-loaded shard, so shards finish at roughly the same time.

Fixed per-job cost

Every job pays a fixed startup cost before it does useful work: provisioning, checkout, toolchain setup, and dependency install. Reduce it with shallow or sparse checkouts, prebuilt runner images that already contain toolchains, and restoring installed dependencies from cache instead of reinstalling them in every job.

Illustrative estimate (assumed numbers, not a benchmark): fixed cost F = 3 min, total test work W = 40 min, N shards.

  • Wall clock ≈ F + W/N
  • Billed minutes ≈ N·F + W

At N = 4, wall clock is 13 min and billed time is 52 min. At N = 20, wall clock is 5 min and billed time is 100 min. Concurrency limits make over-sharding worse, because extra jobs wait in a queue and the wall-clock gain disappears.

One gate for branch protection

Don't mark individual matrix jobs as required checks, because their names change with every plan. Instead, add a single gate job that depends on all of them and runs with if: always(). It fails if any dependency failed or was cancelled, and it treats intentionally skipped jobs as passing. This gate always reports a status, so path-skipped work never leaves a required check stuck pending, and a crashed planner can't produce a false pass.

Expect debugging to be harder. The job list differs on every run, so log the planner's output to make each run reproducible.

name: ci
on: [pull_request]

jobs:
  plan:
    runs-on: ubuntu-latest
    outputs:
      targets: ${{ steps.affected.outputs.targets }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # plan needs history to diff against the base branch
      # Placeholder script: prints a JSON array such as ["libs/core","apps/api"]
      - id: affected
        run: echo "targets=$(./ci/affected-targets.sh origin/${{ github.base_ref }})" >> "$GITHUB_OUTPUT"

  test:
    needs: plan
    if: needs.plan.outputs.targets != '[]'  # docs-only change: zero test jobs
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      max-parallel: 8  # cap fan-out; extra jobs would only queue
      matrix:
        target: ${{ fromJSON(needs.plan.outputs.targets) }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 1  # shallow: test jobs don't need history
      - run: ./ci/test.sh "${{ matrix.target }}"

  gate:  # the only job marked as a required check
    needs: [plan, test]
    if: always()  # always reports, even when test was skipped
    runs-on: ubuntu-latest
    steps:
      - name: Fail on any failed or cancelled dependency
        if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
        run: exit 1
      - run: echo "All work passed or was intentionally skipped"

A GitHub Actions workflow where a plan job emits affected targets as JSON for a capped dynamic matrix, and a single always-running gate job treats skipped jobs as passing while failing on any failure or cancellation.

Keeping a correctness backstop and knowing when to avoid this approach

Affected detection is only as good as the dependency graph, and the graph drifts. A new import path, a moved config file or a hand-edited build rule can silently remove an edge, and from then on, changes that should trigger a test don't. Pull requests stay green while main slowly accumulates breakage nobody ran. The fix is a full build of main on a schedule or after every merge. It is not gated on the planner and it runs every target. When it fails on a commit whose pull request passed, treat that as a graph bug, not a flaky test, and add the missing edge.

The usual blind spots are dependencies that never appear as static imports:

  • Dynamic imports, such as plugins loaded by name from a string or a registry.
  • Runtime configuration, where a service's behaviour changes because a shared YAML or environment file changed.
  • Shared database schemas. A migration in one package breaks queries in another with no code-level link.
  • Code generation, where the generator or its schema input changes but the generated output isn't tracked as a dependency.
  • Implicit file reads, such as test fixtures, templates or data files opened by path at runtime.

Some repositories are better off limiting affected builds or skipping them. If your build tool can't see most real dependencies, the graph is guesswork. If global inputs like lockfiles or base images change on most pull requests, nearly every run selects everything anyway, and you have paid for the planner without getting much back. If no one owns the tooling, the graph will drift faster than anyone repairs it. In each of these cases, a full build with good caching is simpler and safer.

Alternatives and impact on cost and engineering time

…is often the cheaper option. Before building a planner, compare it with the other ways to buy back pipeline time.

Option What it fixes Ongoing cost
Bigger runners CPU-bound steps Higher per-minute rate; doesn't remove unneeded work
Self-hosted runners with warm caches Setup and download time Patching, capacity planning, and isolation. Persistent state can leak between jobs, which is risky for untrusted PRs
Path-filtered static pipelines Obvious unrelated changes Filters miss transitive dependencies and drift from the real graph
Hand-rolled affected script Transitive selection You own the graph logic, its edge cases, and its bugs
Dedicated monorepo build tool Graph, caching, and scheduling together Migration effort, team training, and some lock-in

Illustrative estimate (assumed numbers, not a benchmark). Assume 40 services, 8 affected per PR, and 4 minutes per service build-and-test.

  • Full serial work = services × minutes = 40 × 4 = 160 minutes
  • Affected serial work = affected × minutes = 8 × 4 = 32 minutes
  • Parallel critical path ≈ longest job + fixed per-job overhead ≈ 4 + ~1 = about 5 minutes

Your runner bill tracks the 32 minutes of compute. Developers wait for the roughly 5-minute critical path.

To decide whether the work pays off, set the time saved against the upkeep:

  • Time saved per week = PRs per week × wait minutes saved per PR
  • Upkeep per week = platform-team hours spent keeping the graph, cache keys, and runners correct

If upkeep costs more than the time saved, choose the simpler option: bigger runners or a full build with good caching.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to monorepo ci, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.