How it works: how Nx decides a cache hit or miss

Before Nx runs a cacheable task, it computes a hash of everything declared as able to affect that task's result. The hash covers:

  • the project's source files
  • the inputs of the projects it depends on
  • environment variables and runtime values that you explicitly declare
  • the command being run and its options

Nx then looks that hash up in the cache. If it finds a match, that is a cache hit: Nx skips the task and restores both the terminal output and the files the task produced, such as the dist directory (Cache Task Results). If it finds no match, that is a cache miss. Nx runs the task and stores the result under the new hash.

 source files ─────────┐
 dependency inputs ────┤
 env vars / runtime ───┼──► hash ──► cache lookup
 command + options ────┘                │
                             ┌──────────┴──────────┐
                            hit                   miss
                             │                     │
              restore terminal output       run the task,
              + restore output files        store result under hash

Only the hash decides whether a cache entry is still valid. Entries do not expire after a set time, and switching branches does not invalidate them. A result built last month on another branch is reused if the inputs hash to the same value. A one-character change to a file that counts as an input causes a miss, even if you made it a minute ago.

This leads to two kinds of problems. If an input is missing from the hash, Nx can return a stale result. If the inputs are too broad, Nx misses the cache on changes that have nothing to do with the task. Most cache problems come from one of these two.

Teams cache tasks to avoid repeating work. Locally, re-running tests on unchanged projects returns almost instantly. In CI, projects that a pull request did not affect get their build and test results from the cache instead of running again.

+-------------------+
| source files      |--+
+-------------------+  |
+-------------------+  |
| dependency inputs |--+
+-------------------+  |     +------------+     +--------------+
+-------------------+  +---->| task hash  |---->| cache lookup |
| declared env vars |--+     +------------+     +--------------+
| / runtime values  |  |                               |
+-------------------+  |                  +------------+------------+
+-------------------+  |                  |                         |
| command + options |--+                 hit                       miss
+-------------------+                     |                         |
                                          v                         v
                             +-------------------------+ +---------------------+
                             | skip the task           | | run the task        |
                             | restore terminal output | | store output + files|
                             | restore output files    | | under the new hash  |
                             | (e.g. dist/)            | +---------------------+
                             +-------------------------+

  The hash alone decides validity: no time-based expiry, no branch check.

Nx combines a task's declared inputs into a hash and looks it up in the cache. A hit restores the terminal output and output files, and a miss runs the task and stores the result under the new hash.

Which tasks should be cached, and which should not

That speedup only holds if a cached result is a correct stand-in for actually running the task. Nx can only replay a result safely when the same inputs always produce the same outputs, and when the task does nothing outside its outputs and terminal log. The Nx caching docs say cacheable operations must be side-effect free. Their example is an e2e run against a backend API, which can't be cached because the backend can change the result.

A task that breaks this rule doesn't fail loudly. It gives you a wrong cache hit. The hash matches, Nx replays an old "passed", and the thing that actually changed never shows up in the hash. Network responses, database contents, the current time and random seeds can all change the result without changing any file Nx tracks.

Sort your targets into cacheable and non-cacheable before you tune any inputs. If a task shouldn't be cached at all, adjusting its inputs won't make it safe.

Target type Recommendation Reason
build Cache Its output depends only on source, config and dependencies
test (unit) Cache Deterministic when tests don't use the network or shared state
lint Cache Its result depends only on source files and lint config
e2e Don't cache (when it calls a live backend) Backend state isn't part of the hash, so a replayed pass can be wrong
deploy Don't cache Its whole purpose is a side effect, so skipping it means nothing ships
codegen Cache only if generated from checked-in files Pulling a schema over the network makes the output depend on external state

Tests that open a real database connection or call another service are really integration tests. Treat them the same way as e2e.

Who should use this approach

Tuning cache inputs is worth the effort for anyone working in an Nx workspace where cacheable targets run often enough that misses and stale hits have a real cost:

  • Platform and build engineers who own nx.json. They define namedInputs and targetDefaults, so their choices decide hit rates and correctness for every project in the repo.
  • Developers who add or change targets. A new build script that reads an env var, a codegen step, or a project-level override in project.json can each introduce a missing input or a wrong outputs path.
  • Teams running CI on a monorepo with many projects. The more projects a pull request leaves untouched, the more work a correct cache can skip, and the more places a wrong input can serve a stale artifact.
  • Teams that use or plan to use a shared remote cache. A bad hit there reaches every machine that reads from the cache, not just the one that produced it.

A small workspace with a handful of fast tasks gets less out of this. The default inputs are often good enough, and a full rerun costs little. The checks in this article still matter if a task embeds environment-specific values, because a stale artifact is a correctness problem regardless of repo size.

When to tune cache inputs

Review inputs and outputs at specific points rather than continuously:

  • When CI keeps rebuilding projects a change didn't touch. This usually means inputs are too broad, for example a build hashing spec files or a large sharedGlobals set.
  • When a no-op rerun misses. Running the same task twice with no changes should hit. If it doesn't, something volatile is in the hash.
  • When a deployed or packaged artifact doesn't match the source. This points to a missing input or an outputs path that doesn't match where the task writes.
  • When you add a target, executor, plugin or environment variable. Each can change what a task reads or writes, so check the resolved configuration with nx show project at that point.
  • After upgrading Nx or its plugins. Inferred targets and their default inputs can change, so compare the resolved inputs and outputs before and after.
  • Before turning on a shared remote cache. Fix false hits locally first, since a remote cache spreads them.

Don't tune inputs for a task that shouldn't be cached at all, such as a deploy or an e2e run against a live backend. Turn caching off for that target instead.

Named inputs: default, production and sharedGlobals

Inputs become manageable once you name them. The namedInputs block in nx.json defines reusable file sets that targets reference by name instead of repeating glob lists. Most workspaces use three.

default

default usually contains {projectRoot}/**/* plus sharedGlobals. Every file in the project and every shared root file is part of the hash. This is the safe baseline for targets like test and lint, which really do read everything.

production

production starts from default and subtracts what a build never reads. Negated patterns such as !{projectRoot}/**/*.spec.ts, !{projectRoot}/tsconfig.spec.json, !{projectRoot}/jest.config.ts and !{projectRoot}/**/*.md remove test files, test configuration and documentation. Editing a test or a README then no longer invalidates build.

The ^ prefix applies a set to the project's dependencies instead of to the project itself. A build target with inputs ["production", "^production"] hashes its own production files and the production files of every library it depends on. A spec change in a shared library therefore doesn't rebuild every consumer.

sharedGlobals

sharedGlobals holds root-level files that affect many projects, such as tsconfig.base.json, the root lint config, and scripts your CI pipeline invokes. These files must be written as {workspaceRoot}/... paths. A {projectRoot} glob only matches files inside the project directory, so it never sees root files. If you forget them, a compiler option change produces stale cache hits.

The trade-off

Every file in sharedGlobals invalidates every project when it changes. If you add a large set, a single edit to a root script triggers a full rebuild. You can split it into narrower inputs, such as a TypeScript set referenced only by build targets and a lint set referenced only by lint. This is more precise, but you have to keep the sets accurate as the repo changes.

{
  "namedInputs": {
    // Everything in the project plus shared root files: safe for test and lint
    "default": ["{projectRoot}/**/*", "sharedGlobals"],

    // default minus files a build never reads
    "production": [
      "default",
      "!{projectRoot}/**/*.spec.ts",
      "!{projectRoot}/tsconfig.spec.json",
      "!{projectRoot}/jest.config.ts",
      "!{projectRoot}/**/*.md"
    ],

    // Root-level files: {projectRoot} globs never match these
    "sharedGlobals": [
      "{workspaceRoot}/tsconfig.base.json",
      "{workspaceRoot}/.eslintrc.json",
      "{workspaceRoot}/scripts/ci-build.sh"
    ]
  },
  "targetDefaults": {
    "build": {
      // Own production files plus production files of every dependency (^)
      "inputs": ["production", "^production"],
      "cache": true
    },
    "test": {
      // Tests read spec files, so hash the full default set
      "inputs": ["default", "^production"],
      "cache": true
    }
  }
}

An nx.json that defines default, production and sharedGlobals named inputs and uses ^production so that build hashes only the production files of the project and its dependencies.

Fixing over-broad inputs and missing inputs

Most cache problems come from one of two failure modes, and they fail in opposite directions.

Over-broad inputs: needless misses

A build target that hashes default treats every file in the project as an input, including spec files, Storybook stories and Markdown. Editing a test then rebuilds the library and every dependent project. Point build targets at production and ^production, where the caret applies the dependencies' production set. Keep default and ^production on test, because tests depend on spec files.

The trade-off is that every exclusion assumes the excluded file cannot affect the output. If a build step reads a fixture or a config file that production filters out, Nx serves the old artifact. A stale artifact costs more than a slow build because nothing reports it as wrong.

Missing inputs: false hits

Nx hashes only what you declare. If a bundle embeds API_BASE_URL or reads a feature flag, add { "env": "API_BASE_URL" } so a staging build cannot replay a production artifact. Values from tools work the same way. { "runtime": "node --version" } hashes the command's output, which covers Node upgrades and codegen tool versions.

Problem Symptom Fix
Over-broad inputs Misses after spec or doc edits production on build targets
Missing env or runtime input Wrong artifact restored env / runtime entries
Per-run values in the hash Every CI run misses Move them to a non-cached step

Do not add per-run values such as build numbers, commit SHAs or timestamps as inputs to a cacheable task. Each one changes on every run, so the cache never hits. Stamp them in a separate uncached step after the build.

// nx.json (Nx reads this file as JSON with comments)
{
  "namedInputs": {
    // Every file in the project. Tests need all of it.
    "default": ["{projectRoot}/**/*"],
    // Files that can reach the build output. Each exclusion assumes the file
    // cannot change the artifact, so check build scripts before adding one.
    "production": [
      "default",
      "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)",
      "!{projectRoot}/**/*.stories.@(js|jsx|ts|tsx|mdx)",
      "!{projectRoot}/**/*.md",
      "!{projectRoot}/jest.config.[jt]s"
    ]
  },
  "targetDefaults": {
    "build": {
      "cache": true,
      "dependsOn": ["^build"],
      "inputs": [
        // Edits to specs, stories or docs no longer rebuild this project or its dependents.
        "production",
        "^production",
        // Values baked into the bundle, so a staging build cannot replay a production artifact.
        { "env": "API_BASE_URL" },
        { "env": "FEATURE_NEW_CHECKOUT" },
        // Hashes the command's output, so a Node upgrade invalidates the cache.
        { "runtime": "node --version" }
        // Leave out build numbers, commit SHAs and timestamps. They change on
        // every run and would make every build miss. Stamp them in a separate uncached step.
      ]
    },
    "test": {
      "cache": true,
      // Tests read spec files, so they keep the broader set for their own project.
      "inputs": ["default", "^production"]
    }
  }
}

An nx.json that limits build targets to production inputs, keeps the full default set on test targets, and adds env and runtime inputs so builds with different config or a different Node version don't share cached artifacts.

Outputs, targetDefaults and resolved configuration

Outputs must match where files are written

The outputs array tells Nx which paths to archive after a task runs and restore on a cache hit. Nx does not inspect what the task actually wrote. If your build emits to {workspaceRoot}/dist/{projectRoot} but outputs lists {projectRoot}/dist, the first run stores an empty artifact. The next run reports a cache hit and replays the terminal log, but the dist folder is missing or still holds files from an older build. A downstream docker build or deploy step then packages stale artifacts without any error. Coverage has the same failure mode: if test writes to coverage/{projectRoot} and that path is not in outputs, cached test runs skip the reports your CI uploads.

How configuration resolves

targetDefaults in nx.json sets behavior for every target with a given name or executor across the workspace. A target in a project's project.json is layered on top, and project-level values win. This lets one service write to a non-standard folder without affecting the rest of the repo.

The cost is drift. When someone updates the central production inputs or default outputs, projects with their own overrides keep the old definitions, and nothing warns you.

Inspect the resolved result

Don't merge nx.json, project.json and plugin-inferred targets in your head. Run nx show project my-api to print the final configuration for each target, including resolved inputs, outputs and cache. Add --json to diff that output across projects or check it in CI.

#!/usr/bin/env bash
set -euo pipefail

nx show project my-api

nx show project my-api --json | jq '.targets.build | {cache, inputs, outputs}'

jq '.targetDefaults.build' nx.json

expected=$(nx show project my-api --json | jq -c '.targets.build.outputs')
for p in $(nx show projects --with-target build); do
  actual=$(nx show project "$p" --json | jq -c '.targets.build.outputs')
  if [ "$actual" != "$expected" ]; then
    echo "$p: build outputs = $actual (my-api uses $expected)"
  fi
done

nx show project my-api --json | jq -r '.targets.test.outputs[]?'

Uses nx show project with --json and jq to inspect resolved cache, inputs and outputs, compare them with targetDefaults, and flag projects whose build outputs differ from a reference project.

Debugging a cache miss step by step

Start with a baseline. Run the task, then run it again without changing anything. If the second run is not a cache hit, part of the input set changes on every run, for example a file the task writes into its own inputs, a timestamp in generated code, or an environment variable that differs per shell. Fix this first, because you can't diagnose anything else until a no-op rerun hits.

Next, isolate the cause. Change one suspected input at a time, such as a markdown file, a root config file or an env var, and rerun after each change. If the hash moves for a file that cannot affect the output, the input is too broad. If the hash stays the same after a change that does affect the output, an input is missing.

In CI, compare across machines and runs as well as within one. The usual causes are:

  • Env vars the CI system sets per job, such as build numbers or commit SHAs, that a target lists as inputs
  • Files generated by an earlier step
  • Lockfile changes
  • Different Node or tool versions between runner images

nx reset and --skip-nx-cache only hide the problem for one run. Neither changes the input definition, so the next run computes the same wrong hash and the miss or stale hit comes back.

Symptom Likely cause Fix
Miss on every run Volatile input: per-run env var, generated file, timestamp Remove it from inputs or make generation deterministic
False hit (stale result) Missing input: config file, env var or dependency output not in the hash Add it to the target's inputs or a named input
Hit that restores nothing outputs missing or pointing to the wrong path Correct outputs to match where the task writes

Remote cache, alternatives, and impact on cost and engineering time

A local cache only helps the machine that produced it. A shared remote cache lets one CI run reuse another run's work, and lets a developer reuse CI's. The same reach applies to mistakes: one incorrect hit, such as a build hashed without an environment variable it depends on, is restored for every consumer until the inputs change. Treat the remote cache as a trust boundary. Let only CI on protected branches write to it, and give developer machines and pull-request builds from forks read-only access.

Approach Effect on correctness Cost
Disable caching No false hits Every task reruns on every run
Routinely clear the cache Hides bad hits temporarily Repeated cold runs; root cause remains
--skip-nx-cache Bypasses the cache for one run Useful for diagnosis, wasteful as a default
Explicit, correct inputs Hits only when outputs would match Upfront configuration effort

Correct inputs reduce cost in two ways. CI skips work it has already done, and developers wait less for builds and tests. A false hit also has a cost: someone has to debug behaviour that does not match the source.

Illustrative estimate (assumed numbers, not a benchmark). Assume 200 CI runs per day, 15 minutes of cacheable work per run, and a 60% hit rate after inputs are fixed.

Saved compute per day = runs × cacheable minutes × hit rate = 200 × 15 × 0.6 = 1,800 minutes.

Next, assume one false hit per month that costs 4 engineer-hours to diagnose. Compare that recurring debugging time against the compute saved. Then work to drive false hits toward zero rather than turning the cache off.

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 invalidation, 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.