How Nx computes a task hash and what a cache miss means

Computation caching lets Nx skip work it has already done. Before running a target, Nx computes a hash of everything that could affect the result. If a cached entry exists for that hash, Nx restores the terminal output and the declared output files instead of executing the command. In a growing monorepo this matters most to the backend and platform engineers who own CI time. Every unnecessary rerun of a build or test suite costs runner minutes and queue time.

What goes into the task hash

The hash combines the task's resolved inputs:

  • Project files matched by the target's input patterns, often through named inputs.
  • Workspace-level files such as root config and lockfiles, if they are included.
  • External dependency versions that the project resolves.
  • Target configuration and the command itself, including options and arguments.
  • Declared environment variables and runtime inputs, for example the output of a command such as a Node version check.
  • Hashes of dependent tasks, so a change upstream propagates downstream.

Any change to any of these produces a new hash and a miss. This is by design: Nx cannot know whether a change is irrelevant unless you tell it.

Two different kinds of "it reran"

Separate the hash changed from the hash matched but nothing was restored. The second case has different causes:

  • Caching is disabled for the target.
  • The outputs setting doesn't cover what the task writes, so a restore leaves files missing.
  • Someone ran with --skip-nx-cache.
  • The local cache and a shared or remote cache disagree about what exists.
+----------------------+  +----------------------+  +----------------------+
| Project files        |  | Workspace files      |  | External deps        |
| (input patterns)     |  | (root cfg, lockfile) |  | (resolved versions)  |
+----------------------+  +----------------------+  +----------------------+
+----------------------+  +----------------------+  +----------------------+
| Target config +      |  | Env vars + runtime   |  | Dependent task       |
| command, options     |  | inputs (declared)    |  | hashes (upstream)    |
+----------------------+  +----------------------+  +----------------------+
           |                         |                         |
           +-------------------------+-------------------------+
                                     |
                                     v
                   +----------------------------------+
                   | Task hash of resolved inputs     |
                   | change any input -> new hash     |
                   +----------------------------------+
                                     |
                       cache entry for this hash?
               +---------------------+----------------+
            no |                                      | yes
               v                                      v
+-----------------------------+     +-----------------------------------+
| CACHE MISS: hash changed    |     | Hash matched, restore can still   |
| task runs, result cached    |     | fail or be skipped:               |
| (compare hash inputs)       |     | - cache disabled for target       |
+-----------------------------+     | - outputs miss written files      |
                                    | - run with --skip-nx-cache        |
                                    | - local vs remote cache disagree  |
                                    +-----------------------------------+

Nx combines six kinds of resolved inputs into one task hash, and a task can rerun either because that hash changed or because a matching cache entry was not restored.

Inspecting resolved inputs with nx show project

Before changing anything, look at the configuration Nx actually uses. Running nx show project <project-name> prints the fully merged project: every target with its inputs, outputs, cache flag and dependsOn, after inferred plugin configuration, targetDefaults and namedInputs have been applied. On versions that support it, the --web flag opens the same data in a browser, where you can expand each target and see which plugin or default set each value. This step matters because inferred configuration hides what is hashed. A target may have no entry in project.json at all, yet a plugin can give it inputs you never wrote.

Named inputs are labels that expand into file patterns. In a typical generated nx.json, these are common:

  • default usually means every file under the project, {projectRoot}/**/*, plus sharedGlobals.
  • sharedGlobals usually holds workspace-wide files such as CI config or shared tooling config, so a change there invalidates every task that references it.
  • production usually starts from default and removes files that shouldn't affect build output.

Those definitions are set in your workspace, not built into Nx, so check yours.

Two tokens set the scope of each pattern. {projectRoot} resolves to the project's directory. {workspaceRoot} resolves to the repository root. Patterns that begin with ! are negations: !{projectRoot}/**/*.spec.ts or !{projectRoot}/**/*.md keep test and doc edits from invalidating a build.

Output formats and flags differ between Nx versions. Run nx show project --help against your installed version before relying on a specific option.

# Confirm which options your installed Nx version supports
npx nx --version
npx nx show project --help

# Print the fully merged configuration for one project
# (inferred plugin config + targetDefaults + namedInputs already applied)
npx nx show project my-app

# Same data as JSON, so you can pull out exactly what a target hashes
npx nx show project my-app --json | jq '.targets.build | {inputs, outputs, cache, dependsOn}'

# Inputs for the test target, often different from build
npx nx show project my-app --json | jq '.targets.test.inputs'

# Where supported, open the interactive view to see which plugin
# or default set each value
npx nx show project my-app --web

# Named inputs are defined in your workspace, not built into Nx.
# Read them directly to see what labels like "production" expand to.
jq '.namedInputs' nx.json

# A typical result looks like this (yours may differ):
# {
#   "default": ["{projectRoot}/**/*", "sharedGlobals"],
#   "sharedGlobals": ["{workspaceRoot}/.github/workflows/ci.yml"],
#   "production": [
#     "default",
#     "!{projectRoot}/**/*.spec.ts",   # test edits don't invalidate build
#     "!{projectRoot}/**/*.md"         # doc edits don't invalidate build
#   ]
# }

# Check the targetDefaults that may be filling in inputs you never wrote
jq '.targetDefaults' nx.json

Commands that print the merged target configuration for a project, extract a target's inputs, outputs, cache and dependsOn, and show how the workspace's named inputs expand into scoped and negated file patterns.

A repeatable workflow for nx cache miss debugging

Treat the cache as a black box you probe with controlled experiments. Each step tests one hypothesis.

Step 1: Establish a baseline

Run the target task once so a result is stored. Then run it again immediately, changing nothing. Use the same command, branch, environment variables, and shell.

Step 2: Interpret the second run

If the second run misses, something in the hash changes between identical invocations. That means an input is nondeterministic. Typical sources are:

  • a generated file rewritten with a timestamp
  • an environment variable whose value differs per shell
  • a runtime input whose command output varies

Diffing your own edits will not help here, because you made none.

If the second run hits, hashing is stable. The miss you originally saw came from a real change, and the next job is to identify which one.

Step 3: Isolate the invalidating input

Apply candidate changes one at a time and rerun after each. Candidates include touching a source file, a test file, a root config, or the lockfile, or setting an environment variable. If you change several things at once, a miss tells you only that at least one of them matters.

Step 4: Compare hash details

For each run, capture the hash details Nx reports. These list the task hash and, depending on your version, a breakdown of contributing inputs such as files, runtime values, and dependency hashes. Compare two runs side by side. The input whose hash differs is your culprit, and you can map it back to a specific inputs entry or named input.

Using nx reset and disabling the daemon

Run nx reset, or set NX_DAEMON=false, only to rule out stale workspace or project-graph state while you diagnose. If a miss disappears after a reset and then comes back, the reset hid the cause without fixing it. A recurring miss needs a change to inputs, not a habit of clearing state.

#!/usr/bin/env bash
# Probe the Nx cache with controlled runs. Project and target names are placeholders.
set -euo pipefail
TASK="my-app:build"
mkdir -p cache-debug

# Step 1: baseline run stores a result in the cache.
npx nx run "$TASK" > cache-debug/run1.log 2>&1

# Step 2: identical rerun, same shell, env and branch.
NX_VERBOSE_LOGGING=true npx nx run "$TASK" > cache-debug/run2.log 2>&1

# Nx reports a cache hit when it reads output from the cache
# instead of running the command.
if grep -qi "cache" cache-debug/run2.log; then
  echo "Check run2.log: a cache hit means hashing is stable, so isolate one change at a time."
else
  echo "No cache message found: an input may be nondeterministic."
fi

# Step 3: change ONE candidate input, then rerun.
# Example: set a single env var that a task input may reference.
MY_FEATURE_FLAG=on npx nx run "$TASK" > cache-debug/run3.log 2>&1

# Step 4: put the hash details your Nx version reports for each run
# into the cache-debug/ files, then diff them to find the differing input.
diff cache-debug/run2.log cache-debug/run3.log || true

# Diagnosis only: rule out stale workspace or project-graph state.
# If the miss returns after this, the cause is still in your inputs.
npx nx reset
NX_DAEMON=false npx nx run "$TASK" > cache-debug/run4-no-daemon.log 2>&1

A bash script that runs a baseline task, reruns it unchanged, changes one input, and diffs the saved logs, using nx reset and NX_DAEMON=false only to rule out stale state.

Common culprits and local-versus-CI misses

Most recurring misses come from a small set of causes. Check the cheapest ones first.

Symptom Likely cause Why the hash changes
A second identical run still misses The task writes codegen output, build info or timestamped files inside the project root Default inputs cover project files, so the task changes its own inputs
Many projects miss after a pull A lockfile or root config change pulled in through workspace-level inputs Shared inputs feed every hash that references them
Misses happen only in CI An env var declared as an input has a different value there A different value produces a different hash
A miss follows a run that wrote files Undeclared outputs land in locations that are hashed Leftover files alter the next computation
A project misses with no source changes An upstream dependency's task rehashed Dependency hashes cascade into dependents

Local-versus-CI misses need extra checks:

  • Env and runtime inputs: CI usually sets variables that your shell does not. Any runtime input that captures command output, such as a tool version, will differ if the images differ.
  • OS and toolchain: Different platforms or toolchain versions change runtime inputs and can change generated files.
  • Checkout state: A fresh CI checkout lacks gitignored artifacts left over from earlier local builds. Those artifacts can still affect what gets hashed locally.
  • Install state: A non-frozen install can resolve different dependency versions than your local tree.

Some misses come from the tools Nx runs, not from Nx. Compilers and bundlers that embed absolute paths, build timestamps or unstable ordering produce outputs that differ on every run. When those outputs feed downstream tasks as inputs, the downstream hashes change too. Nx can only work around this by excluding those files from inputs. The durable fix is a deterministic build setting in the tool itself.

Fixing misses by narrowing inputs deliberately

Inputs are a contract: everything that can change a task's output belongs in the hash, and nothing else does. Start in nx.json with namedInputs that match how targets actually behave. A production set for build targets typically starts from default and excludes test files, stories, and lint config with negated patterns such as !{projectRoot}/**/*.spec.ts. Test targets keep the broader default. Assign these through targetDefaults so each target type gets one consistent definition instead of per-project drift.

Declare anything outside the file tree explicitly. If a build reads NODE_ENV or an API base URL, add an env input. If output depends on the toolchain, add a runtime input such as the Node version. Undeclared inputs cause the more dangerous failure: false cache hits that serve stale artifacts.

Tasks that write generated files into their own input globs will hash differently on every run. Move codegen output to a directory covered by outputs and excluded from inputs, or into a separate target that the consumer depends on.

Once you override inferred inputs, you own them. When plugin defaults improve, your overrides won't pick up the change, so review them whenever you upgrade Nx.

Validation runbook

Record these checks for each changed target:

  1. Run the task twice in a row. The second run should be a cache hit.
  2. Edit a file that affects the output. The next run should miss.
  3. Edit a file that should not matter, such as a spec or README. The next run should hit.

Repeat the checks in CI and on a clean clone, not only on your own machine.

{
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "sharedGlobals": [
      "{workspaceRoot}/tsconfig.base.json",
      { "runtime": "node --version" }
    ],
    "production": [
      "default",
      "!{projectRoot}/**/*.spec.ts",
      "!{projectRoot}/**/*.stories.tsx",
      "!{projectRoot}/.eslintrc.json",
      "!{projectRoot}/README.md",
      "!{projectRoot}/src/generated/**/*"
    ]
  },
  "targetDefaults": {
    "codegen": {
      "cache": true,
      "inputs": ["{projectRoot}/schema/**/*"],
      "outputs": ["{projectRoot}/src/generated"]
    },
    "build": {
      "cache": true,
      "dependsOn": ["codegen", "^build"],
      "inputs": [
        "production",
        "^production",
        { "env": "NODE_ENV" },
        { "env": "API_BASE_URL" }
      ],
      "outputs": ["{projectRoot}/dist"]
    },
    "test": {
      "cache": true,
      "inputs": ["default", "^production"]
    }
  }
}

An nx.json that gives build targets a production input set that excludes specs, stories, lint config and generated code, declares env and runtime inputs, and moves codegen into its own cached target that build depends on.

When to avoid narrowing inputs and the trade-offs involved

Narrowing is not always the right fix. A cache miss costs time. A false cache hit costs correctness, and it fails silently: Nx restores an artifact built from different sources, the task reports success, and the stale output ships. For build, package, and release targets, keep inputs broad when you are unsure. An unnecessary rebuild is cheap compared with debugging a deploy that contains last week's code.

Broad defaults have their own cost, and it grows with the repository. A root config file or lockfile listed as an input for every project means one dependency bump invalidates every task. Lint and unit test targets tolerate tighter inputs better than build targets. Their failure mode is usually a missed warning, not a broken artifact.

Approach Failure mode Visibility Best fit
Broad inputs Cascading misses across many projects Obvious: slow runs Build and release targets
Narrow inputs False hits that restore stale output Silent Lint, tests, docs
Per-run values as inputs Every run misses Shows up in hash details Almost never appropriate

Build IDs, timestamps, and commit SHAs fragment the cache when they enter the hash, because no two runs ever match. Inject these at deploy time, not at build time. If you must include them, scope them to the single target that embeds them.

A shared or remote cache multiplies both outcomes. A correct input definition saves time for every developer and CI agent. A wrong one spreads stale artifacts to all of them. Review changes to input definitions the way you review changes to CI pipelines.

Alternatives and the impact on cost and engineering time

When a miss keeps recurring, teams usually reach for one of five responses. They differ mainly in where the cost ends up.

Approach Correctness risk Time cost
Disable caching for the target None; it always runs Full runtime on every invocation
Run nx reset repeatedly None, but it hides the cause Rebuilds everything, and the miss comes back
Skip the cache in CI None in CI; local runs still diverge Full CI compute on every pipeline
Keep broad default inputs Low; it over-invalidates Frequent unnecessary reruns
Narrow inputs deliberately Stale results if an input is missing Upfront analysis, then ongoing review

The first four approaches are safe because they give up reuse. Only narrowing changes the hit rate, and it is also the only one that can produce a wrong result.

Illustrative estimate (assumed numbers, not a benchmark). Assume 40 pipelines a day, a 6-minute target, and narrowing that raises the hit rate from 30% to 80%.

Minutes saved per day = pipelines × task minutes × (new hit rate − old hit rate) = 40 × 6 × 0.5 = 120 minutes of agent time.

Developers waiting on those pipelines recover part of that time too. There is also a less visible saving: once hashes are stable, nobody spends an afternoon working out why build reran again.

Set that saving against what custom inputs cost to own. Every narrowed definition encodes assumptions about which files a tool reads. New config files, new code generators, and tool upgrades can all break those assumptions without any error, so the inputs need a named owner and should be revisited whenever the toolchain changes.

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.
  • 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.