What Nx cache outputs are

Nx is a build system for monorepos that can skip work it has already done. Each target, such as build, test or lint, can be marked cacheable. When a cacheable target runs, Nx stores two things under a hash of the task's inputs: the terminal output, and the files the task produced. The outputs property on a target tells Nx which files those are. It is a list of paths or glob patterns, usually written with tokens like {projectRoot}, {workspaceRoot} and {options.outputPath}.

"Cache outputs size" means the total bytes and file count matched by those patterns for one cache entry. That is what Nx writes to the local cache, uploads to a remote cache if one is configured, and later restores into the workspace on a cache hit. Nx does not judge whether a file is useful. If a pattern matches it, it is stored and restored. "Trimming artifacts" means making outputs, the tools behind each target, or both, produce and capture only the files that something downstream reads.

How the cache mechanism works, step by step

  1. Hash. Before running a target, Nx computes a hash from the task's declared inputs. These include source files, configuration, the outputs of dependency tasks, and any environment variables or runtime values listed as inputs.
  2. Lookup. Nx checks the local cache directory for an entry with that hash. If a remote cache is configured, it checks that as well.
  3. Miss. If there is no match, Nx runs the target. When the target finishes, Nx collects the files matched by outputs, stores them with the captured terminal output, and uploads the entry to the remote cache if one is in use.
  4. Hit. If the hash matches, Nx does not run the target. It restores the matched files into their original locations and replays the terminal output.

Steps 3 and 4 are the only points where outputs matters, and both scale with what the patterns match. The rest of this article covers how much they cost, how to measure them, and how to reduce them.

How Nx cache restores work and why outputs size matters

When Nx runs a target, it first computes a hash from the task's inputs: source files, configuration, dependency outputs, and relevant environment. If that hash matches a stored entry, Nx skips execution and does two things: it restores the files matched by the target's outputs into the workspace, and it replays the terminal output captured during the original run. To you the task looks like it ran. The cost is entirely in moving files.

That cost is not fixed. A local restore has to copy or write every matched file, so time scales with both file count and total bytes. Ten thousand small files can be slower than one large bundle because each file adds filesystem overhead. A remote cache adds more steps. The artifact is archived when it is stored, downloaded over the network when it is needed, extracted, and only then placed in the workspace. Archive size drives transfer time, and file count drives extraction time. On a cold CI runner with an empty local cache, every task in the graph pays these costs.

This is why outputs should be treated as a contract. It describes what downstream tasks and developers actually consume, such as the compiled package or the type declarations a dependent build reads. It is not a place to capture everything a tool happens to write. Anything matched beyond that contract adds restore cost and buys nothing.

This guide is for backend and platform engineers who own CI pipelines and the build configuration of an Nx monorepo.

 +------------------+     +----------------------------+
 | Task inputs:     |---->| Compute task hash          |
 | src, config,     |     +-------------+--------------+
 | dep outputs, env |                   |
 +------------------+                   v
                          +----------------------------+
                          | Hash matches stored entry? |
                          +---+--------------------+---+
                           no |                    | yes
                              v                    v
                  +-----------------------+  +---------------------------+
                  | Run target, then      |  | Cache hit: skip run       |
                  | archive + store files |  +-------------+-------------+
                  | matched by `outputs`  |                |
                  +-----------------------+                |
                    +--------------------------------------+
                    v                                      v
 +------------------------------------+     +------------------------------+
 | Remote cache, cold runner:         |     | Local cache:                 |
 |  download archive (~ total bytes)  |     |  copy/write every matched    |
 |  extract archive  (~ file count)   |     |  file (~ count + bytes)      |
 +-----------------+------------------+     +--------------+---------------+
                   +-------------------+-------------------+
                                       v
                 +-------------------------------------------+
                 | Restore `outputs` files into workspace,   |
                 | then replay captured terminal output      |
                 | Contract: only what downstream tasks and  |
                 | developers consume; extras = restore cost |
                 +-------------------------------------------+

On a cache hit, Nx skips the run and restores the files matched by outputs. Restore cost grows with file count and total bytes, and a remote cache adds download and extraction. Keeping outputs to what downstream consumers need keeps that cost down.

Who should trim cache outputs

Trimming is aimed at the people who own build and CI configuration in an Nx workspace: platform engineers, build or developer-experience engineers, and backend or frontend leads who maintain nx.json and project.json files. It pays off most when several of these apply:

  • CI uses a remote cache, and runners start cold. Every hit is a download and extraction, so artifact size shows up directly in pipeline time.
  • Cache hits are common but not cheap. Hits take a noticeable share of the time a full run would take.
  • Targets produce many files. Typical examples are JavaScript or TypeScript builds that emit source maps and declaration files next to every module, test targets that write full coverage reports, and builds that copy dependencies into dist.
  • Many projects inherit the same targetDefaults. One broad pattern is multiplied across the workspace, so fixing it once has a wide effect.

It is less useful for small workspaces where only the local cache is used and restores already feel instant, or for pipelines where the bottleneck is clearly task execution or scheduling. The measurement section below shows how to tell which case you are in. Application developers who don't edit build configuration still benefit, because their local and remote cache hits get faster. They mainly need to know that a file missing after a cache hit can point to an outputs pattern that is too narrow.

Measuring what actually lands in the cache

Before touching any outputs arrays, confirm that restore size is actually the bottleneck. Inspect the local cache first. Its location depends on your Nx version and any cacheDirectory setting in nx.json. Each entry is stored under a hash, so running du -sh on the entries and sorting by size quickly shows which tasks produce the heaviest artifacts.

Next, trace a large entry back to its target. Run that target once, then list and size everything under its declared output paths. du gives the total bytes, and find ... -type f | wc -l gives the file count. Track both numbers, because ten thousand small files can restore more slowly than one large archive. Each file adds per-file overhead for writing to disk and extracting it, and that overhead adds up.

Then compare durations in CI logs. For the same target, record the time on a cache hit and the time on a cache miss. If hits are consistently fast and only misses are slow, the cost is in execution, and trimming outputs will not help. If hits take a meaningful share of miss time, restore is the problem. Check whether hit time grows with artifact size.

Trimming is not worth doing in a few situations:

  • Flat hit times regardless of size. Network latency or remote cache backend throughput dominates.
  • High file count with modest bytes. Reducing file count matters more than reducing size.
  • Hits are fast but the pipeline is still slow. Task-graph scheduling or limited parallelism is holding the critical path. In that case, cache artifact size is not the cause.
#!/usr/bin/env bash
set -euo pipefail

CACHE_DIR="${1:-.nx/cache}"
PROJECT="${2:-web}"
OUT_DIR="${3:-dist/apps/web}"   # must match the target's declared outputs

echo "== 10 largest cache entries in $CACHE_DIR =="
du -sh "$CACHE_DIR"/* 2>/dev/null | sort -rh | head -n 10

echo "== Cache miss: force execution =="
time npx nx run "$PROJECT:build" --skip-nx-cache

echo "== Output size and file count for $OUT_DIR =="
du -sh "$OUT_DIR"
find "$OUT_DIR" -type f | wc -l

echo "== Cache hit: restore only =="
time npx nx run "$PROJECT:build"

A shell script that ranks local Nx cache entries by size, measures the byte total and file count under one target's output directory, and times a forced cache miss against a local cache hit so you can tell whether restore or execution dominates.

Common sources of cache artifact bloat

Most bloat comes from files a tool writes as a side effect into a directory that the outputs config captures wholesale. For each file type, ask one question: does any downstream task or deploy step actually read it from the restored artifact?

Artifact Usually consumed downstream? Recommended handling
Source maps (*.map) Only for error-reporting uploads or local debugging Keep for release builds that upload them. Otherwise disable generation in CI or exclude them.
TypeScript build info (*.tsbuildinfo) Yes, if dependents rely on tsc --build incremental checks These files are small. Keep them where incremental builds depend on them, or tsc may rebuild everything.
Declaration files (*.d.ts) Yes for buildable libraries consumed by other projects. Rarely for bundled apps Emit them only for libraries. Turn emission off for application targets.
Coverage reports, test result dirs Only by a merge or report step Cache them under the test target. Keep them out of build outputs entirely.
node_modules or vendored deps in dist Rarely; the deploy image usually reinstalls them Stop copying them. This is often the single largest item.
Intermediate build dirs (e.g. pre-bundle tsc output) No, once the bundler has run Write them outside the cached path or delete them before the task finishes.
Logs, temp files No Redirect them to a temp location outside the output folder.

Source maps and declaration files often double the file count of a JavaScript build. That matters more than raw size for the restore cost described above. Copied dependencies tend to dominate byte size.

Tightening the outputs config

Start by listing what downstream consumers actually read from a target. A build target that declares {projectRoot}/dist caches everything the tool writes there. A narrower declaration such as {projectRoot}/dist/**/*.js together with {projectRoot}/dist/**/*.d.ts keeps the runtime code and type information and leaves out maps, reports and scratch files. A test target that declares {workspaceRoot}/coverage/{projectRoot} can usually be cut down to the single summary file your CI step parses.

Use the tokens consistently. {projectRoot} resolves per project, so one pattern works across the whole workspace. {workspaceRoot} is only for tools that write outside the project folder. Avoid hardcoded relative paths. They break when a project moves, and they can match the wrong directory without any error.

Put the shared pattern in targetDefaults in nx.json, keyed by target name, so every build inherits it. Override the pattern in a project's own configuration only when its tool behaves differently, such as a bundler that emits to a custom outputPath. In that case, reference {options.outputPath} rather than repeating the literal path. A project-level outputs array replaces the default; it is not merged with it. Any override therefore has to list every path that project needs.

Narrow patterns are brittle. Suppose a tool upgrade renames .d.ts output to .d.mts, or moves assets into a subfolder. Nx does not report an error. It caches less, and restored builds then have files missing. Section 6 covers how to detect this.

"targetDefaults": {
  "build": { "cache": true, "outputs": ["{projectRoot}/dist"] },
  "test":  { "cache": true, "outputs": ["{workspaceRoot}/coverage/{projectRoot}"] }
}

"targetDefaults": {
  "build": {
    "cache": true,
    "outputs": [
      "{projectRoot}/dist/**/*.js",
      "{projectRoot}/dist/**/*.d.ts"
    ]
  },
  "test": {
    "cache": true,
    # the one file the CI step parses
    "outputs": ["{workspaceRoot}/coverage/{projectRoot}/coverage-summary.json"]
  }
}

"targets": {
  "build": {
    "options": { "outputPath": "dist/apps/web" },
    "outputs": [
      "{options.outputPath}/**/*.js",
      "{options.outputPath}/**/*.css",
      "{options.outputPath}/assets/**/*"
    ]
  }
}

Before-and-after outputs configuration. Broad directory outputs are replaced with token-based globs in nx.json targetDefaults. One project overrides the defaults with {options.outputPath}, and a comment notes that renamed files drop out of the cache without any error.

Alternatives to narrowing globs: split targets and change tool output

Narrowing globs trims what Nx archives. You can also change what gets produced or where it ends up. These options often hold up better than a carefully tuned glob.

Split targets. Move coverage, test reports, and debug bundles into their own targets, such as a test target with no coverage and a separate test-coverage target that only the coverage job runs. Most cache hits then restore the small artifact. The large one is fetched only by the pipeline that reads it. Reports that nothing downstream consumes can be marked non-cacheable so they are never uploaded.

Change tool output. If no step in CI reads source maps or .d.ts files, configure the compiler or bundler not to emit them in CI. Files that are never written cost nothing to hash, upload, or restore. You can also send intermediates like build-info files and temp directories to a path outside the declared outputs, so they never enter the cache.

Approach Restore size Config surface Cache entries Risk
Narrower globs Smaller Low Unchanged Silent misses after tool changes
Split targets Smallest on common path Moderate: new targets and pipeline wiring More Low
Disable emission in CI Smaller Low More if env-keyed CI and local entries diverge
Intermediates outside outputs Slightly smaller Low Unchanged Incremental state lost on restore

The CI/local split needs deliberate handling. Suppose a CI configuration disables source maps and that difference is not part of the hash. A developer can then restore a CI artifact that has no maps. To avoid this, declare the controlling environment variable or configuration as a task input. CI and local builds will produce separate cache entries that cannot be swapped, which is correct but lowers the hit rate between the two.

{
  "name": "web",
  "targets": {
    "build": {
      "executor": "nx:run-commands",
      "cache": true,
      "inputs": ["production", "^production", { "env": "CI" }],
      "outputs": ["{projectRoot}/dist"],
      "options": { "command": "tsc -p tsconfig.build.json", "cwd": "apps/web" },
      "configurations": {
        "ci": { "command": "tsc -p tsconfig.ci.json" }
      }
    },
    "test": {
      "executor": "nx:run-commands",
      "cache": true,
      "inputs": ["default", "^production"],
      "outputs": [],
      "options": { "command": "vitest run", "cwd": "apps/web" }
    },
    "test-coverage": {
      "executor": "nx:run-commands",
      "cache": true,
      "inputs": ["default", "^production"],
      "outputs": ["{projectRoot}/coverage"],
      "options": { "command": "vitest run --coverage", "cwd": "apps/web" }
    },
    "lint-report": {
      "executor": "nx:run-commands",
      "cache": false,
      "options": { "command": "eslint . --format json --output-file /tmp/web-lint.json", "cwd": "apps/web" }
    }
  }
}

A project.json that splits coverage into its own target, builds with a CI-only tsconfig that can disable source maps and declarations, keys the build hash on the CI variable, and leaves an unconsumed lint report uncached.

Validating the change and when to avoid trimming

Before merging an outputs change, prove that a cache hit alone produces a working tree. Start from a clean checkout with no dist directories and no local cache, so every artifact must come from the remote cache. Run the targets you changed, confirm they report cache hits, and then run what consumes them: test suites that import built packages, packaging steps that zip or containerize the output, and deploy scripts that read manifests or asset folders. Next, build the same commit from scratch with caching disabled and diff the two trees. Any file that exists only in the fresh build is something your new globs dropped. Decide whether a downstream step needs it before you accept the change.

Under-declaring outputs is the dangerous direction. An over-broad glob costs bandwidth and restore time. A missing file costs correctness, and only on cache hits. A cache miss rebuilds everything locally, so the failure appears only when a particular runner restores rather than rebuilds. That makes it intermittent and hard to trace back to a config change made days earlier.

Some artifacts are worth their size:

  • Source maps. Keep them if production error tracking or debugging depends on them, even though they are often the largest files in the output.
  • Declaration files. Keep .d.ts output for libraries consumed by other projects, or type-checking breaks only for consumers.

Finally, expect a burst of cache misses after merging. Changing outputs or tool flags changes the hash, so existing entries stop matching. Measure restore times only after the cache has repopulated.

Impact on cost and engineering time, and preventing regressions

Illustrative estimate (assumed numbers, not a benchmark)

Assume a target caches 400 MB across 30,000 files, restores at about 50 MB/s, and after trimming caches 60 MB across 2,000 files. Also assume 10 restores of this target per pipeline and 200 pipelines per day.

Daily seconds saved = (old size − new size) ÷ throughput × restores per pipeline × pipelines per day

Before After
Artifact size 400 MB 60 MB
File count 30,000 2,000
Transfer per restore ~8 s ~1.2 s
Daily restore time (10 × 200) ~16,000 s ~2,400 s

That is about 13,600 seconds, or roughly 3.8 runner-hours per day. Treat this as a floor. A single throughput figure ignores the per-file cost of extracting and writing tens of thousands of small files, so a large drop in file count often saves more than the size ratio suggests. Restores that run in parallel save fewer wall-clock minutes than runner-minutes. Developers waiting on local or remote cache hits see the same per-restore gain.

Weighing the costs

Splitting targets adds config to maintain. It can also store more total bytes, because two smaller entries may overlap. Compare that against the estimate. A few hours of setup and some extra storage usually pay back quickly when a target restores thousands of times a day. For a target that restores twice a week, the extra config is often not worth it.

Preventing regressions

Add a scheduled CI job or periodic script that walks the cache directory, groups entries by project and target, and reports size and file count. Fail the job, or post a warning, when an entry grows past a set threshold or past a percentage over its recorded baseline. Also require reviewers to read outputs changes alongside the build config that produces those files. Most bloat comes from a tool flag that starts emitting source maps or intermediates, not from someone widening a glob.

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 nx cache outputs size, 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.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

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.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • 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.