What Nx actually hashes for a task

Before Nx runs a cacheable task, it computes a single hash. If that hash matches an entry in the local or remote cache, Nx restores the outputs and terminal log. If it doesn't, the task runs. Nx does not compare timestamps or inspect what changed, and it has no notion of "close enough." To debug a miss, you have to find which part of the hash moved.

The hash is built from these parts:

  • Resolved file inputs. The inputs on the target, often expressed through namedInputs such as default (typically every file in the project root) or production (usually default minus tests, specs, and lint config). These named inputs are conventions defined in your workspace configuration, not fixed behavior. Whatever globs they resolve to, every matching file's content is hashed.
  • Dependency inputs. An input prefixed with ^, such as ^production, applies that named input to every upstream project in the graph. Editing a shared library therefore changes the hash of every service that depends on it.
  • Target and options. The target name, its configuration, and any flags passed on the command line.
  • Runtime inputs. The output of a command that Nx executes, such as a tool version check.
  • Environment inputs. Values of declared environment variables.
  • External dependencies. Package versions resolved from the lockfile.

The diagram shows how these parts fold into one hash, and how ^ inputs pull hashes from upstream projects.

In backend repos, a persistent miss is rarely a cache bug. It usually means an input definition is broader or noisier than the team realizes.

 +---------------------+          +---------------------+
 | libs/shared-db      |          | libs/auth           |
 | production files    |          | production files    |
 +----------+----------+          +----------+----------+
            |      ^production (upstream)    |
            +---------------+----------------+
                            v
 +----------------------------------------------------------+
 | Task: api:build                                          |
 |  [file inputs]   default / production globs -> contents  |
 |  [dep inputs]    ^production -> upstream project hashes  |
 |  [target]        name + configuration + CLI flags        |
 |  [runtime]       output of declared commands             |
 |  [env]           values of declared env variables        |
 |  [external]      package versions from lockfile          |
 +-----------------------------+----------------------------+
                               v
                     +-------------------+
                     |  one task hash    |
                     +---------+---------+
                               v
                     +-------------------+
                     | local/remote cache|
                     +----+---------+----+
                   match  |         |  no match
                          v         v
              +----------------+  +----------------+
              | restore outputs|  | run the task   |
              | and log        |  |                |
              +----------------+  +----------------+

Every input category, including upstream project hashes pulled in through ^production, folds into one task hash, and a cache lookup on that hash alone decides whether Nx restores outputs or runs the task.

How cache-miss debugging works

Because the cache decision depends on one hash, debugging a miss means working backwards from that hash to the single input that changed. The process in the rest of this article has three steps:

  1. Establish what is hashed. Print the resolved configuration for the target, not the file you edited, and expand each named input into the globs it actually covers.
  2. Isolate the change. Run the task twice under controlled conditions (same machine with no edits, or local versus CI). Collect the per-input hashes from each run and diff them. Inputs that did not change drop out, and what remains names the file, environment variable, runtime command, external package, or upstream project that differs.
  3. Fix the input definition, then verify in both directions. Remove noise from the inputs or make the task deterministic. Then confirm that an unchanged rerun hits and that an edit to a file the task really reads causes a miss.

The approach works because Nx's caching is deterministic. Identical inputs always produce the same hash, so any miss is fully explained by the inputs that differ between two runs. When an upstream project's hash is the changed input, the same three steps apply recursively to that project.

Seeing the inputs that really apply with nx show project

Before diffing anything, find out what Nx actually resolved for the target. Engineers usually reason from the project.json they edited, but the configuration Nx hashes is a merge of several layers: targetDefaults in nx.json, the project's own target config, and targets or inputs inferred by plugins that read tool config files. A target can carry inputs that appear in none of the files you opened.

Run nx show project api --json to print the merged project configuration. To pull out a single target's inputs, pipe it through jq: nx show project api --json | jq '.targets.build.inputs'. For a browsable view of the same data, including which plugin or file contributed each target, use nx show project api --web.

A trimmed result for a Node service might look like ["production", "^production", { "externalDependencies": ["typescript"] }]. In this example the target ignores an inputs array set in project.json because an inferred target replaced it. That array has no effect on the hash. The production entry is also only a name. To see what it covers, you still have to look up its definition under namedInputs in nx.json or in the project's own config. If that definition starts from default, it can include every file in the project root, including scratch scripts and local .env files.

The structure of this output, and how much provenance the web view shows, has changed across Nx releases. Check the output from your installed version before writing scripts that depend on specific fields.

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

PROJECT=api
TARGET=build

echo "== Inputs declared in project.json (what you edited) =="
jq --arg t "$TARGET" '.targets[$t].inputs // "none declared"' "apps/$PROJECT/project.json"

echo "== Inputs Nx resolved (what is actually hashed) =="
npx nx show project "$PROJECT" --json \
  | jq --arg t "$TARGET" '.targets[$t].inputs'
echo "== namedInputs.production definitions =="
jq '.namedInputs.production // "not overridden in project.json"' "apps/$PROJECT/project.json"
jq '.namedInputs.production' nx.json
npx nx show project "$PROJECT" --web

A shell script that compares the inputs declared in project.json with the inputs Nx resolves for the api build target, then looks up what the production named input expands to.

A repeatable debugging loop: run twice, then diff the hash details

Start with a clean working tree and run the task once to warm the cache. Then run the exact same command again without touching anything. The second run should be a cache hit. If it misses, something the task does during execution is changing one of its own inputs. Common culprits are generated code written into src, a build that rewrites a lockfile, or test output written inside a tracked directory. Checking with git status after the first run often shows the file directly.

If both local runs hit but CI misses, the inputs are fine and the cause is environmental. Something differs between the two machines: an environment variable, a runtime version, a file that exists only in one checkout, or a different set of dependency projects.

To find the specific input, collect the per-input hash details from each run. Use whatever mechanism your Nx version provides to record or display them. Reduce each run to a sorted list of input name and hash pairs, then diff the two lists. Most lines will match. The diff usually shows one or two changed lines, and each changed line names the exact file, environment variable, runtime command, or dependency project that broke the cache. If the only change is in a dependency project's hash, repeat the same process on that project.

Run nx reset or disable the daemon only after you have the diff. Both discard the cached hashes and run history you need for the comparison. If they make the miss go away, you have hidden the symptom without learning which input caused it.

#!/usr/bin/env bash
set -euo pipefail
TASK="api:build"

test -z "$(git status --porcelain)" || { echo "working tree not clean"; exit 1; }

npx nx run "$TASK"
git status --porcelain    # anything listed here was written by the task into a tracked path
npx nx run "$TASK"        # a miss here means the task changes its own inputs

flatten() {
  # One "input hash" pair per line, sorted so the diff lines up.
  jq -r 'to_entries[] | "\(.key) \(.value)"' "$1" | sort
}

diff <(flatten run-a.json) <(flatten run-b.json) || true

A shell loop that runs a task twice from a clean tree, checks git status for files the task wrote into its own inputs, and diffs the sorted per-input hashes of two runs, leaving nx reset and disabling the daemon for last.

Common backend causes of cache misses and how to fix them

Most persistent misses come from the workspace hashing files that it writes itself, or files that change on every machine and every run.

Self-invalidation

If a task writes into a path its own inputs cover, the next run sees different files and misses. Common examples are generated Prisma, OpenAPI, protobuf or GraphQL clients under the project root, dist or log files inside the project, local .env files, coverage reports, updated snapshots, and version files that embed a build timestamp or commit SHA. Nx ignores what .gitignore and .nxignore exclude, so either ignore these files or exclude them with negated patterns in a named input.

Workspace-wide and environmental inputs

tsconfig.base.json, the root package.json, the lockfile and broad {workspaceRoot} globs can feed every project's hash. Editing one of them invalidates everything. Environment inputs behave the same way. If an env or runtime input reads a CI run ID, a per-job variable or an absolute machine path, it guarantees a unique hash on every run.

Upstream propagation

A ^ input pulls in the dependencies' inputs, so a change in a shared library re-hashes its consumers. dependentTasksOutputFiles hashes the dependencies' built outputs instead, which helps only if those outputs are deterministic.

Symptom Likely cause Fix
Second identical run misses Task writes into its own inputs Ignore or negate generated paths
Every project misses together Root config or lockfile changed Narrow {workspaceRoot} inputs
Local hits, CI always misses Per-run env or runtime value Hash a stable value or drop it
Consumers miss after a no-op library rebuild Non-deterministic upstream output Strip timestamps from the output
cat > .nxignore <<'EOF'
apps/*/coverage/
apps/*/logs/
apps/*/.env.local
EOF

cat > nx.json <<'EOF'
{
  "namedInputs": {
    "default": ["{projectRoot}/**/*"],
    "backendSrc": [
      "default",
      "!{projectRoot}/src/generated/**",
      "!{projectRoot}/dist/**",
      "!{projectRoot}/**/__snapshots__/**",
      "!{projectRoot}/src/version.ts"
    ]
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": [
        "backendSrc",
        "^backendSrc",
        "{workspaceRoot}/tsconfig.base.json",
        { "externalDependencies": ["@prisma/client", "typescript"] }
      ]
    }
  }
}
EOF

A .nxignore file and an nx.json named input with negated patterns keep generated code, output, snapshots and version files out of the build hash. The externalDependencies input hashes only the packages the build uses.

When narrowing inputs is the wrong fix

Every fix in the table above makes inputs smaller, so it has a failure mode in the opposite direction. If inputs are too narrow or outputs are declared wrong, Nx returns a false hit. It replays a terminal log and restores artifacts that came from different sources. A slow build costs minutes. A stale build that passes CI can ship a binary that does not match the commit. Whoever edits a target's inputs owns that risk, so review those edits the way you would review a change to the deploy pipeline.

Watch for three traps:

  • Excluding generated files. Dropping generated clients or ORM types from inputs is safe only when codegen runs as its own cacheable upstream task with declared outputs, and the consuming task depends on it. Otherwise, a schema change regenerates the files without invalidating anything downstream.
  • Trusting externalDependencies. This input tracks the packages you name. A transitive bump to a compiler, bundler or plugin that changes emitted code can slip past it.
  • Treating every miss as a bug. Some tasks read the clock, call the network or introspect a live database schema. A miss is the correct result there. Set cache: false on those tasks, or split them so the nondeterministic read becomes an explicit input such as a committed schema snapshot.

Verify every narrowing in both directions. First, run the task twice and confirm the second run hits. Then edit a file the task actually reads, such as a source file or a config it loads at runtime, and confirm the next run misses. If it still hits, your inputs are wrong.

Alternatives: hashing the environment vs removing the dependency

Once you know a task reads something from its environment, there are five ways to respond. They differ in what they cost you.

Declare env or runtime inputs. This is the honest fix: the hash now reflects what the task reads. It also fragments the cache. If a runtime input captures a toolchain version that differs between laptops and CI images, those machines stop sharing cache entries.

Make the task environment-independent. Read configuration at process start instead of baking it into build output. Pin toolchains so every machine produces the same result. The hash then needs no environment inputs, and sharing works again.

Split codegen into its own cached task. If only a code generation step depends on a schema or tool version, give that step its own target with its own inputs. The compile step then depends on the generated files rather than on the environment.

Mark the task non-cacheable. This fits tasks with side effects, such as migrations or deploys. It is the wrong choice for a build you simply have not debugged yet.

nx reset. This clears local state and hides the symptom. The miss or false hit returns on the next run.

Option Correctness Cache sharing Maintenance Choose when
Declare env/runtime inputs High Fragmented Low The dependency is real and unavoidable
Environment-independent task High Full Medium, one-time Platform teams own the toolchain
Split codegen High Mostly full Medium Only one step is environment-sensitive
Non-cacheable High None Low The task has side effects
nx reset Unchanged Unchanged Recurring Only as a stopgap while you debug

Impact on cost and engineering time

The arithmetic is simple enough to redo with your own numbers.

Illustrative estimate (assumed numbers, not a benchmark). Assume 30 backend projects, a 2-minute build each, and 10 dependency bumps per week. Also assume that with narrowed inputs, a typical bump affects only 3 projects.

  • Wasted build-minutes per bump = (projects invalidated − projects actually affected) × build minutes
  • Per bump: (30 − 3) × 2 = 54 minutes
  • Per week: 54 × 10 = 540 minutes, or 9 hours of CI compute
Lockfile-wide input Narrowed inputs
Builds per bump 30 3
Build-minutes per bump 60 6
Build-minutes per week (10 bumps) 600 60
Ongoing maintenance None Inputs must track real dependencies
Stale-build risk Very low Real if a dependency is left undeclared

The compute figure understates the cost to engineers. Those 54 minutes usually sit on the critical path of a pull request, so developers wait for builds that cannot produce a different result.

The savings are not free. Every narrowed input is a claim about what a task depends on. When someone adds a dependency and forgets to update that claim, the cache can serve an artifact built without it, and nothing fails until runtime. That risk recurs, so budget review time for input changes.

The debugging effort pays off when three conditions hold:

  • Misses are frequent.
  • The affected tasks are slow.
  • Invalidations fan out across many projects.

If misses are rare or builds take seconds, the broad input is cheaper to live with than the upkeep needed to narrow it safely.

Who should use this approach

This process is written for the people who own task configuration in an Nx workspace:

  • Platform and build engineers who maintain nx.json, targetDefaults, named inputs and CI pipelines. They decide what feeds the hash for every project, so they are best placed to narrow inputs safely and review changes to them.
  • Backend service owners whose builds, tests or codegen steps keep rebuilding. They know which files, environment variables and generated artifacts their tasks really read, and they can confirm whether an input belongs in the hash.
  • Anyone running a shared remote cache. When laptops and CI share cache entries, environment-sensitive inputs decide whether those entries are reused, and a false hit can affect every consumer of the cache.

If you only consume the workspace and do not edit target configuration, the first two sections of this article are still useful for reporting a miss accurately. Make the fix itself with whoever owns the input definitions, because narrowing inputs carries the false-hit risk described above.

When to use it

Work through the debugging loop when a miss is unexpected and repeatable:

  • A second identical run misses on the same machine with no edits. This almost always means the task writes into its own inputs.
  • Local runs hit but CI consistently misses, or two CI jobs on the same commit produce different hashes. This points to environment, runtime or checkout differences.
  • Many projects rebuild together after a change that should affect only a few, such as a single dependency bump or an edit to one shared library.
  • You are about to add env or runtime inputs, or narrow named inputs. Run the diff first so you change the input that actually moved instead of guessing.
  • You suspect a false hit, meaning a restored artifact does not match the sources. The same tooling (resolved inputs and per-input hashes) shows which dependency the hash is missing.

When to avoid it

Skip the investigation, or stop narrowing, in these cases:

  • The miss is correct. A real source edit, a lockfile change that affects the task, or a deliberate configuration change should invalidate the cache. Confirm the changed input is one the task reads, then stop.
  • The task is nondeterministic by design. Migrations, deploys, and tasks that read the clock, call the network or query a live database should be cache: false or split so the nondeterministic read becomes an explicit input. Do not tune inputs until they hit.
  • The payoff is small. If misses are rare or the task finishes in seconds, the time spent diffing hashes and the ongoing upkeep of narrowed inputs can cost more than the rebuilds. See the illustrative estimate above and substitute your own assumed numbers.
  • Nobody can own the narrowed inputs. A narrowed input must be updated whenever the task gains a dependency. Without a reviewer for those changes, a broad input that misses too often is safer than a narrow one that serves stale artifacts.
  • You need a quick unblock. nx reset can unblock you, but treat it as a temporary workaround, not a diagnosis. Capture the hash diff first if you intend to fix the cause later.

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 miss debugging, 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.