How Nx forms a task hash and why every-run misses are usually input problems
Before running a task, Nx computes a task hash, a fingerprint of everything declared as able to affect that task's output. If the local cache or remote cache already holds an entry under that hash, Nx replays the stored terminal output and restores the output files instead of executing the task. This is only safe because the hash is meant to capture every relevant input. If two runs produce the same hash, Nx treats them as interchangeable.
The hash combines several categories of input:
resolved file sets ───────────┐
(project files per inputs) │
│
upstream projects ── ^ ──────►│
(dependency project hashes) │
├──► task hash ──► cache lookup
external deps (lockfile) ────►│
runtime inputs (commands) ───►│
env inputs (variables) ──────►│
target config + CLI args ────►┘
A change to any single input produces a new hash. Nx reports the miss but does not say which input changed. That has a practical consequence. If a task misses on every run, even with no code changes, the cause is almost never a caching bug. Something you declared as an input is changing between runs. Common examples are a generated file inside the project root, a timestamp written by an upstream build, or an environment variable that differs per run.
This matters most for backend and platform engineers who own CI duration and monorepo conventions. One volatile input in a shared targetDefaults entry or a widely used named input can turn hits into misses across every project that inherits it.
+-----------------+ +-----------------+
| upstream lib-a | | upstream lib-b |
| (own task hash) | | (own task hash) |
+--------+--------+ +--------+--------+
| |
+----------+----------+
| ^ dependency project hashes
v
+------------------------+ +---------+----------+
| resolved file sets |---->| |
+------------------------+ | |
| external deps |---->| |
| (lockfile) | | TASK HASH |
+------------------------+ | (one digest) |
| runtime inputs |---->| |
| (command output) | | |
+------------------------+ | |
| env inputs (variables) |---->| |
+------------------------+ | |
| target config + args |---->| |
+------------------------+ +---------+----------+
|
v
+----------+----------+
| cache lookup |
| local / remote |
+----+-----------+----+
hit | | miss
v v
restore outputs run task,
+ replay logs store result
Every declared input category, including the ^ hashes of upstream projects, folds into one task hash, so a change in any single input produces a cache miss without Nx naming which input changed.
How the debugging approach works
Nx does not tell you which input changed, so the approach in this article recovers that information indirectly in four stages:
- See the real inputs. Use
nx show projectto view the inputs Nx resolved after merging plugins,targetDefaultsandproject.json. The inputs you wrote are often not the complete list. - Prove the instability. Clear local state with
nx reset, then run the same target twice on a clean tree. If the second run misses, an input is changing between runs with no edits from you. - Isolate the input. Compare per-input detail if your Nx version exposes it. Otherwise bisect the target's
inputsuntil one entry is left that changes the hash. - Fix and verify in both directions. Make that input stable, or exclude it if it really cannot affect output. Then confirm that two clean runs hit the cache and that a real source edit still misses.
The rest of the article walks through each stage, then covers the configuration that shapes the hash and the causes that come up most often.
Who should use this workflow
- Platform and CI owners responsible for pipeline duration and remote cache effectiveness. Every-run misses show up first in your metrics, and the shared
nx.jsonconfiguration is usually yours to fix. - Engineers maintaining shared
namedInputsortargetDefaults. A change there affects every project that inherits it. The diff technique in this article shows the effect before you merge. - Teams adopting inferred targets from Nx plugins. Plugins add inputs that never appear in your repository text, so the resolved view is the only reliable way to audit them.
- Anyone whose target misses on two consecutive runs of an unchanged tree. That symptom always has a specific cause, and this workflow is built to find it.
When to avoid it
- Misses that follow real changes. If hits are only occasionally lower and the misses line up with commits, lockfile updates or branch switches, the cache is working. Debugging inputs will not raise the hit rate.
- Tasks that should never be cached. Deploys, publishes, database migrations and other tasks run for their side effects should have caching disabled (
"cache": false). Making their hash stable would cause them to be skipped when they need to run. - Very fast tasks. If a target finishes in a few seconds, restoring it from cache saves little. The time spent tightening its inputs may never pay back.
- Producers you cannot make deterministic. If a third-party tool embeds unavoidable per-run data in its output, narrowing consumer inputs to hide that data produces stale hits. Disabling the cache for that target is the honest option until the tool can be configured.
- As a substitute for a correct build. If a task is broken, fix it with the cache skipped (
--skip-nx-cache) first. Cache debugging assumes the uncached task behaves correctly.
Reading the inputs Nx actually resolved with nx show project
Before you change any configuration, look at what Nx actually resolved. The project.json file is only one layer. Plugins infer targets from files like vite.config.ts or jest.config.ts and attach their own inputs and outputs. The targetDefaults entries in nx.json then merge on top. A target that looks empty in project.json can still hash a long list of files, environment variables and external dependencies that you never wrote.
Running nx show project <name> shows the merged result. The web view lists every target with its effective inputs, outputs and dependsOn, and often shows where each one came from: a plugin, a default or the project file. The JSON output gives you the same data in a form you can diff. This is the useful part. Save the JSON for a project, change a named input or a targetDefaults entry, save it again, and compare the two. You see exactly which input lists changed, and you don't have to reason through the precedence rules in your head.
Inferred targets have a real trade-off. They remove a lot of configuration, but they also hide where an input comes from. When a plugin adds a broad glob or an environment input, nothing in your repository text points to it. The resolved view is often the only place it shows up.
Flags and output shape have changed across Nx releases. Before you script against this command, check nx show project --help for your installed version.
npx nx show project --help
npx nx show project my-app --web
npx nx show project my-app --json \
| jq '.targets | map_values({inputs, outputs, dependsOn})' \
> /tmp/my-app-before.json
npx nx show project my-app --json \
| jq '.targets | map_values({inputs, outputs, dependsOn})' \
> /tmp/my-app-after.json
diff -u /tmp/my-app-before.json /tmp/my-app-after.json
npx nx show project my-app --json | jq '.targets.build.inputs'
The example snapshots the resolved targets of a project with nx show project --json before and after a configuration change, then diffs the two snapshots to show which inputs, outputs and dependsOn entries changed.
A repeatable workflow for nx cache miss debugging
Use this workflow when cache hits drop to zero for a task that should be stable. It is not something to run as routine practice. If hits are only occasionally lower than expected, a real change is the more likely cause. The steps below are for misses that happen on every run.
1. Clear local state first
Run nx reset. It stops the daemon and clears the local cache and workspace data. A stale daemon can keep serving an outdated project graph, and that produces confusing results. Ruling this out first means everything after it reflects the current configuration.
nx reset is a diagnostic step, not a fix. If misses come back after a reset, the cause is in your inputs.
2. Reproduce on an unchanged tree
Commit or stash your work so git status is clean. Then run the same target twice in a row with identical flags and environment. Record the task hash from each run, using whatever your Nx version prints or stores.
If the two hashes match, your inputs are stable and the miss has a different source, such as a different machine, branch or environment. Section 6 covers that case. If they differ, something the task reads is changing between runs. This step also confirms that nothing else, like a formatter or code generator, touched the tree in between.
3. Narrow to one input category
Inputs fall into a few groups: file sets, environment variables, runtime commands, external dependencies and the hashes of dependency tasks.
- If your version exposes per-input hash detail, compare that detail across the two runs and look for the entry that changed.
- If it does not, bisect. Temporarily remove half of the target's
inputs, rerun twice and check whether the hashes now match. Repeat on the half that still varies until one input is left.
Output formats differ between Nx versions, so read the output yourself before you write a script against it.
Skipping the cache, with --skip-nx-cache, is useful for checking whether the build itself behaves correctly. It hides the symptom without fixing it, so do not leave it in your scripts or CI.
#!/usr/bin/env bash
set -euo pipefail
TARGET="${1:?usage: $0 <project:target>}"
CACHE_DIR="${NX_CACHE_DIR:-.nx/cache}"
if [ -n "$(git status --porcelain)" ]; then
echo "Working tree is dirty; commit or stash first." >&2
exit 1
fi
npx nx reset
snapshot() { ls -1 "$CACHE_DIR" 2>/dev/null | sort; }
before=$(snapshot)
npx nx run "$TARGET"
after_first=$(snapshot)
npx nx run "$TARGET"
after_second=$(snapshot)
echo "Entries added by run 1:"
comm -13 <(echo "$before") <(echo "$after_first")
echo "Entries added by run 2 (should be empty):"
comm -13 <(echo "$after_first") <(echo "$after_second")
git status --porcelain
npx nx show project "${TARGET%%:*}" --json
A script that resets Nx and confirms a clean tree. It then runs one target twice and compares cache-directory snapshots: if the second run adds a new entry, the task hash changed with no edits to the tree. Cache paths and output formats vary by Nx version.
How namedInputs, the ^ prefix and targetDefaults shape the task hash
Most of what you saw in the resolved inputs comes from a few lines in nx.json. Nx workspaces conventionally define three named inputs:
default: every file in the project, written as{projectRoot}/**/*, plussharedGlobals.production:defaultminus files that cannot affect shipped output. These are removed with negated globs such as!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x), along with test configs and lint configs.sharedGlobals: workspace-level files that should affect everything, for example CI workflow definitions or a root tool config.
Named inputs only change the task hash once a target uses them. That happens in targetDefaults. A typical setup gives build the inputs ["production", "^production"] and gives test the inputs ["default", "^production"]. Every project inherits these values unless it overrides them.
The ^ prefix means "this named input, resolved in each dependency project." So ^production hashes the production files of every library the project depends on, but not their specs. Without the negated globs, editing a test in a shared library would invalidate the build of every consumer. With them, only that library's test target misses.
Broad inputs are the safe default because they rarely miss a real dependency. The cost is fan-out. Every default set includes sharedGlobals, and so does every set built on default. A single change to a file listed there invalidates almost every task in the workspace. In a small repository that means a slower CI run now and then. In a large one, a routine config change can trigger a full rebuild. Each file you put in sharedGlobals should be one you are willing to have invalidate the whole workspace when it changes.
{
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": [
"default",
"!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)",
"!{projectRoot}/tsconfig.spec.json",
"!{projectRoot}/jest.config.[jt]s",
"!{projectRoot}/.eslintrc.json",
"!{projectRoot}/eslint.config.[cm]?js"
],
"sharedGlobals": [
"{workspaceRoot}/.github/workflows/ci.yml",
"{workspaceRoot}/babel.config.json"
]
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"cache": true
},
"test": {
"inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"],
"cache": true
},
"lint": {
"inputs": ["default", "{workspaceRoot}/eslint.config.mjs"],
"cache": true
}
}
}
An nx.json defining default, production and sharedGlobals named inputs, using negated globs to keep spec and config files out of production and targetDefaults with ^production to hash only dependencies' shipped files.
The usual culprits, from most to least common
Most every-run misses come from a short list of causes, and checking them in this order usually finds the problem quickly.
| Culprit | Symptom | How to confirm | Fix |
|---|---|---|---|
| Build output or generated code inside an input directory | First run misses, second run misses again, then it stabilises or keeps flipping | A file under dist, a codegen folder or a .d.ts emit appears in the changed-files list between runs |
Move outputs outside the source tree, or exclude them with a negated pattern in the named input |
Untracked files not covered by .gitignore or .nxignore |
Misses only on machines that ran tests or opened an editor | Changed files include logs, coverage/, .env.local, swap files or IDE folders |
Add them to .gitignore or .nxignore |
| Runtime inputs with variable output | Misses across machines, sometimes on every run | The runtime command prints a timestamp, an absolute path or a full tool version | Make the command print only what affects the output, such as the major version |
| Env inputs bound to per-run values | Every CI run misses, local runs hit | The env input holds a build number or commit SHA | Remove it from inputs, or stop the output depending on it |
| Lockfile churn | Many projects miss after an unrelated install | externalDependencies hashes differ while source is unchanged |
Commit the lockfile, use frozen installs, scope externalDependencies |
Generated code ranks first because it hides in plain sight. The task writes into its own input set, so each run produces the input that invalidates the next run.
Before you drop an env var from inputs, check that the output does not depend on it. If a build embeds the commit SHA in a version banner, removing it from inputs gives you stable hashes and stale artifacts. The fix is to inject that value at deploy time so the cached build no longer contains it.
Nondeterministic builds that spread through the graph, and local hits that miss in CI
When one producer poisons the graph
A cache miss in one library often shows up as misses everywhere downstream. If a consumer's inputs include dependentTasksOutputFiles, Nx hashes the upstream build artifacts directly, so a banner timestamp, a generated random ID or an absolute path baked into a source map changes the bytes and the hash on every run. Inputs prefixed with ^ work differently. They hash the dependencies' inputs rather than their outputs, so the producer's instability usually reaches consumers through its own inputs, such as a generated source file or an unstable environment variable. In both cases the instability flows down every edge of the graph.
Do not fix this by removing those inputs from the consumers. That gives you stable hashes over artifacts that really did change, and stale cache hits follow. Make the producer reproducible. Strip or pin timestamps, seed or remove random identifiers, and emit paths relative to the workspace root. This may require bundler or compiler configuration that Nx has no control over.
Local hit, CI miss
The same commit can hash differently on a laptop and a CI runner for these reasons:
- An environment variable declared as an input differs between the two.
- Git line-ending conversion rewrites files on one platform.
- A runtime input captures a tool version, such as Node or a compiler, that differs between machines.
- A file is present in only one checkout. Common examples are an untracked local config, or a file missing in CI because of a sparse or shallow clone.
Run the diff workflow from earlier in this article in both environments and compare the results, so you are not guessing from one side only.
Capturing tool versions involves a trade-off. If output depends on the compiler version, hashing it is correct. The cost is that developers on different versions can no longer share cache entries with each other.
Tightening inputs safely, verifying the fix, and what it costs
When you tighten inputs, start build targets from the production named input and add only what you can show affects output. List only the environment variables your build actually reads into artifacts, not every variable the shell exports. Runtime inputs must print the same value on every run, so a command that echoes a timestamp or a git SHA defeats the cache. Keep declared output directories outside your input globs. If they overlap, each build changes its own next hash.
To verify a fix, run the target twice on a clean tree. Both runs must hit the cache. Then edit a real source file and confirm the next run misses. A fix that passes the first check but fails the second has made inputs too narrow.
Do not narrow an input if you cannot prove a file has no effect on output. A false hit replays a stale artifact without any warning. A slow build is easy to notice, but a stale artifact is not.
| Approach | Hit rate | Stale-artifact risk | Maintenance |
|---|---|---|---|
| Broad default inputs | Low | Very low | None |
Tightened namedInputs |
High | Low if verified | Moderate, centralized |
| Per-project overrides of inferred targets | High | Medium | High: overrides drift from plugin defaults |
| Disable cache for target | None | None | None, but every run pays full cost |
Illustrative estimate (assumed numbers, not a benchmark). Assume 3 developers, 20 runs a day, a 4-minute target, and a hit rate that rises from 10% to 80%.
Minutes saved per day = developers × runs × target minutes × (new hit rate − old hit rate) = 3 × 20 × 4 × 0.7 = 168 minutes.
That figure is worth one day of tightening inputs. It is not worth one shipped stale build.
Alternatives to debugging the hash
Fixing unstable inputs is not the only response to every-run misses. Each alternative has a different cost:
- Disable caching for the target. Set
"cache": falseon targets whose output is inherently nondeterministic or that exist for side effects. This is correct and predictable, but every run pays the full execution time. - Skip the cache per run.
--skip-nx-cacheis useful for diagnosis and for one-off runs where you need a fresh build. As a permanent setting it gives up all caching while leaving the underlying problem in place. - Run less instead of caching more.
nx affectedruns targets only for projects touched by a change relative to a base commit. It reduces work without depending on the cache, but it cannot reuse results across branches or machines, and it still reruns everything affected. - Fix determinism in the build tool. Sometimes the right change is in bundler, compiler or code generator configuration, for example removing embedded timestamps. That fixes the source of the problem, and Nx's hash stabilises as a side effect.
- Coarser CI-level caching. Caching directories such as dependency installs in your CI system, keyed on the lockfile, speeds up setup. It does not skip task execution and gives no per-task reuse.
- A different build system. Other build systems with content-addressed caching face the same problem: an input that changes every run defeats any hash-based cache. Switching tools does not remove the need to find volatile inputs.
How this saves engineering time
The savings come from three places:
- Fewer full re-executions. Each task that moves from an every-run miss to a hit stops consuming developer wait time and CI runner time. The illustrative estimate above, with its assumed inputs, shows how quickly this adds up for even one target on a small team. Because shared
targetDefaultsand named inputs apply across projects, one fix often stabilises many targets at once. - Shorter debugging sessions. Diffing
nx show project --jsonsnapshots and bisecting inputs turns an open-ended investigation into a bounded search. You stop guessing at precedence rules and plugin behaviour and look at the resolved data. - Fewer stale-artifact incidents. The two-sided verification step, where clean runs hit and a real edit misses, catches inputs that were narrowed too far before they ship. Tracing a stale artifact in production usually costs more time than the cache ever saved.
The time cost runs the other way too. Tightened inputs need maintenance when plugins change their inferred targets or when new generated files appear. Keeping that configuration centralized in nx.json, rather than spread across per-project overrides, keeps the maintenance cost small.
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
.envby 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.