What a monorepo build cache does and how it works

A build cache treats each task (compile, test, lint) as a function of its inputs. Before running a task, the tool hashes the source files, relevant config and declared environment variables. If that hash matches a stored entry, it restores the recorded output files and terminal logs instead of executing the task. Nx describes this as restoring "both the terminal output and the files" a task created (Cache Task Results).

This only works if a task is free of side effects, meaning identical inputs always produce identical outputs. Nx's own example is an e2e suite that calls a live backend. The result depends on state outside the hash, so a cached "pass" could hide a real failure.

Local vs. remote cache

A local cache lives on one machine's disk. CI runners are usually ephemeral, so each job starts with an empty cache and gets little or no benefit from it. Most CI savings come from a remote cache shared across runs, branches and parallel agents. A task built on main an hour ago can then be reused by a feature branch's pipeline.

Flow per task on each CI agent:

CI agent A / B / C → hash inputs (sources, config, env) → remote cache lookup → hit: download outputs and logs, then skip the task → miss: run the task, then upload outputs and logs under that hash

The teams that benefit most are backend and platform teams with many projects and frequent pipelines. In those repos, most changes touch only a few projects, so most task hashes in a given run already exist in the cache.

+------------+   +------------+   +------------+
| CI agent A |   | CI agent B |   | CI agent C |
+-----+------+   +-----+------+   +-----+------+
      |                |                |
      +----------------+----------------+
                       |
                       v
        +-----------------------------+
        | Hash task inputs            |
        | sources + config + env vars |
        +--------------+--------------+
                       |
                       v
        +-----------------------------+      +-------------------+
        | Remote cache lookup by hash |<---->| Shared remote     |
        +------+---------------+------+      | cache (all runs,  |
            hit|               |miss         | branches, agents) |
               v               v             +---------+---------+
+-------------------+   +-------------------+          ^
| Download outputs  |   | Run the task      |          |
| and terminal logs |   +---------+---------+          |
+---------+---------+             |                    |
          v                       v                    |
+-------------------+   +-------------------+          |
| Skip task (hit)   |   | Upload outputs    |----------+
| reused, no rerun  |   | and logs by hash  |
+-------------------+   +-------------------+

Each CI agent hashes a task's inputs and checks the shared remote cache; on a hit it restores outputs and logs and skips the task, and on a miss it runs the task and uploads the results under that hash for other runs, branches and agents to reuse.

The savings model as a formula

That reasoning can be turned into a formula that you can write down and check against real data.

Saved minutes per pipeline = Σ (dᵢ × hᵢ) − (H + L + T)

  • dᵢ is the average duration of cacheable task i when it actually runs. Examples are build, unit test, lint and type check.
  • hᵢ is that task's hit rate: the share of runs where its input hash matches an existing cache entry. Measure it per task, not per pipeline. A shared library that most changes depend on will miss far more often than a leaf service.
  • H is the time to compute hashes across the task graph.
  • L is the time spent on cache lookup round trips.
  • T is the time to download artifacts on hits and upload them on misses. When outputs are large, this term can cancel out the gain from caching a fast task.

Monthly CI minutes saved = saved minutes per pipeline × pipelines per month.

The hit rate applies only to cacheable time. Checkout, dependency install, container startup, end-to-end tests and deploy run on every pipeline whether the cache hits or not. The Nx caching docs state that tasks with side effects, such as e2e tests that call a backend, cannot be cached. These steps set a floor: even with a 100% hit rate, a pipeline cannot finish faster than the total time of its non-cacheable steps. Estimates usually get inflated when someone applies the hit rate to total pipeline time instead.

To build this in a spreadsheet, use one row per cacheable task. Put duration in column B and hit rate in column C. Then compute =SUMPRODUCT(B2:B20, C2:C20) - overhead for savings per pipeline, and multiply that result by pipelines per month.

# Illustrative inputs only. Replace them with numbers from your own CI logs.
# Each cacheable task: (name, avg duration in minutes when it runs, per-task hit rate 0..1)
cacheable_tasks = [
    ("build",      6.0, 0.55),  # shared libs change often, so they miss more
    ("unit-test",  8.0, 0.60),
    ("lint",       2.0, 0.80),
    ("type-check", 3.0, 0.70),
]

# Per-pipeline cache overhead in minutes. It is paid on every run, hit or miss.
hash_time     = 0.3  # H: hashing inputs across the task graph
lookup_time   = 0.2  # L: cache lookup round trips
transfer_time = 1.0  # T: artifact download on hits plus upload on misses

# Steps that run every time. They set the floor the cache cannot go below.
non_cacheable = {"checkout": 1.0, "install": 4.0, "containers": 1.5, "e2e": 10.0, "deploy": 3.0}

pipelines_per_month = 2000

# Hit rate is applied to cacheable task time only, never to total pipeline time.
gross = sum(d * h for _, d, h in cacheable_tasks)
overhead = hash_time + lookup_time + transfer_time
saved_per_pipeline = gross - overhead

uncached_total = sum(d for _, d, _ in cacheable_tasks) + sum(non_cacheable.values())
floor = sum(non_cacheable.values()) + overhead  # wall-clock minimum, even at a 100% hit rate

print(f"Gross savings per pipeline: {gross:.2f} min")
print(f"Net savings per pipeline:   {saved_per_pipeline:.2f} min")
print(f"Monthly CI minutes saved:   {saved_per_pipeline * pipelines_per_month:,.0f}")
print(f"Pipeline time: {uncached_total:.1f} min uncached, floor {floor:.1f} min")

A minimal Python script that implements the savings model. It applies per-task hit rates to cacheable time only, subtracts the H + L + T overhead, scales the result to monthly minutes and reports the floor set by non-cacheable steps. All input values are illustrative placeholders.

Gathering inputs and estimating build cache hit rate honestly

Each column in that sheet needs a defensible source. The model is only as credible as its weakest input.

Input Where it comes from Typical pitfall
Per-task duration CI job logs or the build tool's per-task timing output Using the slowest run, or timing on a warm runner that already hides setup cost
Cacheable share of pipeline time Sum of deterministic tasks (build, lint, unit test) ÷ total wall-clock time Counting e2e tests that call live services. They have side effects and cannot be cached safely
Run frequency by branch type CI run history split into main, feature branches and release Treating release builds as cacheable when policy requires a clean build
Per-task hit rate Commit history or a trial period Applying one global percentage to every task

A single global hit rate is misleading because hits depend on how far each change reaches in the dependency graph. A leaf service that rarely changes may hit most of the time. A shared root library sits upstream of everything, so one edit to it invalidates many downstream tasks at once.

You can estimate per-task hit rates in two ways:

  • From history: replay the last few hundred commits and count how often each project fell in the affected set. Its hit rate is roughly one minus that fraction.
  • From a trial: enable the cache for a few weeks and read the actual hit and miss counts.

Plan around the distribution, not the mean. Lockfile changes, dependency upgrades and shared config edits produce weeks with near-zero hits. Averages hide those weeks, but your CI capacity still has to absorb them.

Worked example: low, expected and high scenarios

The figures below are an illustrative estimate. Every input is an assumption chosen to show the method. None of them were measured.

Assumed inputs

  • Task durations per pipeline: build 6 min, unit tests 8 min, type-check 3 min, lint 0.5 min.
  • Overhead: 0.3 min per task for hashing and cache lookup or transfer. It is paid on every run, whether the task hits or misses.
  • Fixed, non-cacheable time: 7 min for checkout, dependency install, end-to-end tests and deploy.
  • Volume: 200 pipelines per day.
  • Baseline: 6 + 8 + 3 + 0.5 + 7 = 24.5 min per pipeline.

Arithmetic

For each task, savings = hit rate × duration − overhead. In the expected case:

  • Build: 0.6 × 6 − 0.3 = 3.3 min
  • Unit tests: 0.5 × 8 − 0.3 = 3.7 min
  • Type-check: 0.6 × 3 − 0.3 = 1.5 min
  • Lint: 0.7 × 0.5 − 0.3 = 0.05 min

The total is 8.55 min saved, which brings the pipeline down to about 15.95 min.

Lint shows why cheap tasks matter. At a 30% hit rate it loses time: 0.15 − 0.3 = −0.15 min. Caching it costs more than running it.

Scenario Hit rates (build/test/type-check/lint) Saved per pipeline Pipeline time Reduction CI minutes saved per day
Low 20% / 15% / 20% / 30% 1.95 min 22.55 min 8% 390
Expected 60% / 50% / 60% / 70% 8.55 min 15.95 min 35% 1,710
High 90% / 85% / 90% / 95% 14.18 min 10.32 min 58% 2,835
Ceiling (100%) 100% across all tasks 16.30 min 8.20 min 67% 3,260

The ceiling row is the main point. Even with perfect hits, the pipeline never drops below 8.2 min. That floor is the 7 fixed minutes plus 1.2 min of overhead. Because of it, cutting fixed steps can matter more than pushing hit rate from 85% to 95%.

Impact on cost and engineering time

A faster pipeline is not the same thing as a cheaper one, and the two can move independently. On parallel agents, wall-clock time is set by the slowest agent (the critical path). Billed minutes are the sum of every agent's runtime, rounded up to the provider's billing increment.

Illustrative estimate (assumed numbers, not a benchmark). Assume four agents that each run 10 min uncached. With the cache, three agents finish in 3 min, and the fourth runs uncacheable e2e tests for 10 min.

  • Wall clock = max(agent times) = max(3, 3, 3, 10) = 10 min, which is unchanged.
  • Billed = Σ(agent times) = 3 + 3 + 3 + 10 = 19 min, down from 40.
Metric Who cares What moves it
Critical-path time Engineers waiting on PRs Hits on the slowest agent and fixed steps
Billed CI minutes Whoever owns the CI bill Hits on any agent, minus cache overhead

Saved minutes become saved money only on per-minute billing. Flat-rate plans, self-hosted runners you pay for whether they are idle or busy, and minimum billing increments can all leave the invoice unchanged. On self-hosted runners, the minutes you free up are queue capacity that other jobs can use.

The cache also adds costs of its own. You pay to store artifacts, you pay network transfer every time an artifact is uploaded or restored, and someone has to run the cache. Running it includes setting eviction policy, controlling access, and preventing cache poisoning, because a writable shared cache can serve tampered build outputs to every downstream job.

Engineering time is saved in two ways. Feedback arrives sooner when the critical path shrinks. Reruns after a flaky failure also get cheaper, because unaffected projects restore from cache and only the failed work executes again. Track both as time-to-green and rerun duration, and report them as time rather than converting them into dollar figures.

Implementing and tuning the cache to raise hit rate

Every input you declare is a term in the hit-rate estimate, so configuration choices change the model's numbers directly. In Nx, inputs and outputs can be set per project or globally in nx.json, as described in the Nx caching docs.

Narrow inputs

A common pattern is a named input such as production that starts from default and excludes !{projectRoot}/**/*.md. The build target then uses production as its inputs. With this setup, a README edit no longer changes the hash, so documentation-only commits restore from cache instead of rebuilding. In the model, this raises the hit rate on exactly the commits that change nothing executable.

Complete outputs

Declare every directory a task writes, for example {workspaceRoot}/dist/{projectName}. If you leave out a generated directory, a cache hit restores only part of the result, and downstream tasks fail or rerun. That shows up as lost savings even though the dashboard reports a hit.

Deterministic outputs

Build timestamps, absolute paths, or random IDs embedded in artifacts change the outputs on every run. When a downstream task hashes those outputs, its hash changes too, so it misses the cache without any error. Strip these values or pin them to a fixed value.

Correctness and security

Narrowing has a failure mode. If an environment variable such as API_URL or a config file outside the project affects the build but is not declared as an input, the cache returns a false hit and serves a stale artifact. That is worse than slow CI. Declare these explicitly, for example with { "env": "API_URL" }.

Finally, restrict write access to the cache. Builds from forks and untrusted branches should only read from it, so they cannot poison entries that release pipelines later restore.

// nx.json (Nx accepts comments in this file)
{
  "namedInputs": {
    // Everything in the project, plus files shared across the workspace
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    // Config outside the project that still affects every build
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"],
    // Narrowed input set: docs and tests don't change build artifacts
    "production": [
      "default",
      "!{projectRoot}/**/*.md",
      "!{projectRoot}/**/*.spec.ts"
    ]
  },
  "targetDefaults": {
    "build": {
      "cache": true,
      "dependsOn": ["^build"],
      "inputs": [
        // This project's production files and those of its dependencies
        "production",
        "^production",
        // Baked into the bundle, so it must be part of the hash.
        // Leaving it out causes false hits and stale artifacts.
        { "env": "API_URL" }
      ],
      // Every directory the build writes, so a cache hit restores the full result
      "outputs": ["{workspaceRoot}/dist/{projectName}"]
    }
  }
}

An nx.json that narrows build inputs to leave out Markdown and spec files, adds the API_URL environment variable to the hash so builds can't get false hits, and lists the full output directory so a cache hit restores the whole artifact.

When to use a build cache, when to avoid it, and alternatives

A cache pays off when three conditions hold together. Most pipeline time goes to cacheable, deterministic tasks such as builds, unit tests, linting and type checks. Those tasks take long enough that a hit saves more than the cost of hashing inputs and transferring artifacts. And the same inputs come up often across branches, agents and reruns, which gives a shared cache something to hit.

Expect little when fixed steps dominate the pipeline. Image pulls, environment provisioning and deploys run the same way whether the cache hits or misses. Gains are also small when most tasks finish in seconds, because artifact transfer takes up most of the saving. Tasks with side effects, such as end-to-end tests against a live backend, cannot be cached safely at all. A remote cache is also one more service to secure and monitor. For a small team with a short pipeline, that work can outweigh the minutes saved.

Alternative What it saves Limitation
Affected-only task selection Skips projects a change cannot reach Reruns every affected task, even when its inputs are unchanged
Dependency install caching Package download and install time Leaves build and test time unchanged
More parallel agents Wall-clock time Total CI minutes stay the same or rise
Splitting slow test suites Wall-clock time on the critical path Adds setup overhead to every shard
Local-only cache Repeat runs on one machine Separate CI runs cannot share results

After rollout, compare pipeline durations with an equivalent pre-rollout period that had similar commit volume. Track hit and miss ratios per task, because one expensive task that always misses can hide behind a healthy overall rate. Then re-run the savings model with the measured values in place of the assumptions.

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 estimate ci time savings, 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.