What goes into an Nx task hash

Before Nx runs a task, it computes a hash from everything it has been told can affect the result. If that hash matches a stored entry, Nx restores the recorded terminal output and the declared output files instead of running the task. If any ingredient differs, the hash differs, and you get an nx cache miss. There is no partial match.

The ingredients fall into a few groups:

  • Project file sets. These are files matched by the target's inputs, usually expressed through namedInputs such as default or production. They are resolved relative to the project or the workspace root.
  • Dependency inputs. An input prefixed with ^ applies the same file set to every project this one depends on in the project graph. A change in a shared library can therefore invalidate tasks in every project that consumes it.
  • Runtime inputs. These are commands whose output is folded into the hash, for example a tool or platform version.
  • Environment variable inputs. Only variables you declare explicitly are included. Nx does not hash your whole environment.
  • External dependency versions. Nx resolves the installed versions of third-party packages from the lockfile, not from the version ranges in package.json.
  • The target itself. This covers the executor or command, its options, and the active configuration.
  • Dependent tasks. When dependsOn pulls in other tasks, their hashes or output files can feed into this one.

Nx only knows what you declare. If a file that affects the build is left out of the inputs, you get a false hit. If you declare a file that changes on every machine or every run, you get constant misses. In practice, most unexpected misses come from input declarations that don't match how the task actually behaves. Bugs in the cache itself are a much less common cause, and the rest of this article works from that assumption.

+------------------------------------------+
| Project file sets   (namedInputs)        |--+
| ^Dependency inputs  (same set on deps)   |--+
| Runtime inputs      (command output)     |--+
| Env var inputs      (declared only)      |--+
| External deps       (lockfile versions)  |--+
| Target              (executor, options,  |--+
|                      configuration)      |  |
| Dependent tasks     (dependsOn hashes)   |--+
+------------------------------------------+  |
                                              v
                                +---------------------------+
                                |         Task hash         |
                                +-------------+-------------+
                                              |
                                              v
                                +---------------------------+
                                |  Matches a stored entry?  |
                                +-----+---------------+-----+
                                  yes |               | no
                                      v               v
                    +------------------------+ +-----------------------------+
                    | Cache hit: restore     | | Cache miss: run the task and|
                    | terminal output and    | | store outputs and terminal  |
                    | declared output files  | | log under the new hash      |
                    +------------------------+ +-----------------------------+

Every declared input, the target definition, and the hashes of dependent tasks combine into a single task hash. The cache is all-or-nothing: an exact match restores the stored terminal output and files, and any difference in any ingredient causes a cache miss.

Where inputs come from and how they merge

Inputs are declared in three places, and Nx combines them in a fixed order. Named inputs in nx.json are reusable sets of file patterns. Most workspaces define three:

  • default matches everything under {projectRoot} plus sharedGlobals.
  • production is default minus test files, specs and lint config.
  • sharedGlobals holds workspace-wide files such as a root tsconfig.base.json or a CI config.

targetDefaults then attach inputs to targets by name. For example, build gets ["production", "^production"], where the ^ prefix means "the production files of my dependencies." Finally, an individual project can declare its own namedInputs and target inputs in project.json, or under the nx key in package.json.

Source Scope Behaviour on name collision
nx.json namedInputs Whole workspace Baseline definition
nx.json targetDefaults Every target with that name Used when the project doesn't set inputs
Project namedInputs One project Replaces the workspace entry entirely
Project target inputs One target Replaces targetDefaults inputs
Plugin-inferred target Projects the plugin detects Set in plugin code, not visible in config files

The replacement rule causes the most confusion. Suppose a project defines production only to exclude one extra directory. That project silently loses every exclusion from the workspace definition, so its test files start affecting the build hash. Nx does not merge the two lists. Whichever definition wins is used as written.

Plugin-inferred targets hide this further. The inputs are generated from tool config files such as a Vite or Jest config, so searching nx.json and project.json turns up nothing to inspect.

{
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": [
      "default",
      "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
      "!{projectRoot}/tsconfig.spec.json",
      "!{projectRoot}/.eslintrc.json",
      "!{projectRoot}/eslint.config.mjs"
    ],
    "sharedGlobals": [
      "{workspaceRoot}/tsconfig.base.json",
      "{workspaceRoot}/.github/workflows/ci.yml"
    ]
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["production", "^production"],
      "cache": true
    },
    "test": {
      "inputs": ["default", "^production"],
      "cache": true
    },
    "lint": {
      "inputs": ["default", "{workspaceRoot}/eslint.config.mjs"],
      "cache": true
    }
  }
}

A representative nx.json defining the default, production and sharedGlobals named inputs, with targetDefaults that attach them to build, test and lint, where ^production pulls in the production files of dependencies.

How to find the cause: nx show project and diffing hash details

Start with the resolved configuration rather than the files you wrote. nx show project <name> prints the project's targets after plugin inference and merging with nx.json defaults. Where your version supports it, the web view (--web) lets you expand each target and see its inputs. This is the first step because the question is not which inputs you wrote but which inputs Nx actually uses. If an input you expected is missing, or an unexpected glob such as {projectRoot}/**/* appears, you have found the problem before hashing anything.

If the configuration looks right, compare actual hashes. Run the task once with verbose output and keep the recorded hash details. Then reproduce the miss, either with a second local run or on the machine that missed, and keep its details too. Nx records a per-input breakdown: file hashes, runtime and environment inputs, external dependency versions, and the hashes of dependent tasks. Sort both breakdowns and diff them. The first entry that differs is your cause. Later differences are usually downstream of it, because a changed dependency hash spreads into everything that depends on it.

The flags, environment variables and on-disk location of the hash details have changed between Nx releases. Check the documentation for your installed version before you script this workflow.

Use this approach whenever a miss is reproducible. Its limit is that the diff shows what changed, not why. If src/generated/schema.ts differs, the diff will not tell you that a codegen step or a formatter rewrote it. You still need to trace which process touched the file.

#!/usr/bin/env bash
# Workflow: check resolved inputs first, then diff two runs' hash details.
set -euo pipefail

PROJECT=my-app
TARGET=build

# 1. See the inputs Nx actually uses, after plugin inference and nx.json merging.
npx nx show project "$PROJECT" --json | jq ".targets.$TARGET.inputs"
# Interactive alternative, where your version supports it:
# npx nx show project "$PROJECT" --web

# 2. Run the task with verbose output, then copy that run's hash details.
npx nx run "$PROJECT:$TARGET" --verbose
# Placeholder: the on-disk location of hash details differs between Nx
# versions. Check your installed version's docs, then copy the file here.
RUN_A=./hash-details-run-a.json

# 3. Reproduce the miss with a second local run or on the machine that missed,
#    then save those details as RUN_B.
RUN_B=./hash-details-run-b.json

# 4. Flatten each file to sorted "path value" lines so the diff is stable.
flatten() {
  jq -r 'paths(scalars) as $p
         | "\($p | map(tostring) | join(".")) \(getpath($p))"' "$1" | sort
}

# 5. Treat the first differing entry as the cause. Later entries are usually
#    dependent-task hashes that changed because of it.
diff <(flatten "$RUN_A") <(flatten "$RUN_B") | head -n 20 || true

# The diff shows WHAT changed, such as src/generated/schema.ts, not WHY.
# Trace which process (codegen, a formatter) wrote that file.

A repeatable bash workflow that checks a target's resolved inputs with nx show project, then flattens, sorts and diffs two runs' hash details so the first differing entry points to the cause of the cache miss.

Common causes of unexpected misses and how each shows up in the diff

Most misses fall into a few patterns, and each one has a recognisable shape in the diff.

Cause Signature in the hash diff Why it happens
Generated or untracked files in a broad glob One or two unfamiliar paths under the project root, often in generated/ or dist/-adjacent folders {projectRoot}/**/* picks up anything on disk, including codegen output and editor artefacts
Lockfile or root config via sharedGlobals Every project's hash changes at once, all pointing to the same root file A file in sharedGlobals feeds every target that includes it, so a lockfile bump touches the whole graph
Timestamps or absolute paths in runtime inputs A runtime command's output differs on every run or every machine The command prints date, pwd or a home directory, which is never stable
Env vars differing between CI and laptops An env input with different values CI sets variables such as CI or a build number that developers do not set locally
OS or architecture Runtime inputs or platform-specific external dependencies differ Native binaries and uname-style commands vary across machines
Dependency ^default pulling in tests Spec files from a library appear in an app's build hash default includes everything, so test edits in a dependency invalidate the consumer's build

The sharedGlobals row is a judgement call. A broad set is simple and safe because nothing relevant gets missed, but every edit to a root file invalidates the whole graph. Splitting root files per target, so that lint depends on the ESLint config and build depends on the TypeScript base config, cuts the blast radius. The cost is that someone has to keep those lists accurate. If a list is missing a file, you get a false cache hit, which is worse than a miss.

Fixing misses with namedInputs, and avoiding false cache hits

Narrowing inputs with a production named input

Define a production entry under namedInputs in nx.json that starts from default and subtracts files that cannot change build output. Negation patterns do the subtraction: !{projectRoot}/**/*.spec.ts, !{projectRoot}/**/*.stories.tsx, !{projectRoot}/jest.config.ts, !{projectRoot}/.eslintrc.json. Point build at ["production", "^production"]. The caret applies the dependency's production set, so editing a test in a library no longer invalidates every app that imports it. test and lint keep default, because specs and lint config are real inputs to those targets.

When inputs are too narrow

The opposite failure is a hash that ignores something the task reads. Common examples are a bundler that inlines process.env.API_URL, a build that shells out to a specific Node version, and a script that reads {workspaceRoot}/config/feature-flags.json from outside the project root. Declare each one explicitly: { "env": "API_URL" }, { "runtime": "node --version" }, and the file path using {workspaceRoot}.

Outputs need the same care. Nx restores only the paths listed in outputs. If a target also writes generated types to a second directory, a cache hit will return an incomplete build, and that problem shows up later in a different task.

The trade-off

A miss costs compute time. A false hit costs debugging time, because the failure is silent and usually lands on someone who didn't touch the config. Lean toward including too much. Remove a file from the inputs only once you have shown it has no effect on the output. Don't narrow inputs for targets that produce release artifacts or that read files outside the project.

{
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "sharedGlobals": [],
    "production": [
      "default",
      "!{projectRoot}/**/*.spec.ts",
      "!{projectRoot}/**/*.stories.tsx",
      "!{projectRoot}/jest.config.ts",
      "!{projectRoot}/.eslintrc.json"
    ]
  },
  "targetDefaults": {
    "build": {
      "cache": true,
      "inputs": [
        "production",
        "^production",
        { "env": "API_URL" },
        { "runtime": "node --version" },
        "{workspaceRoot}/config/feature-flags.json"
      ],
      "outputs": [
        "{projectRoot}/dist",
        "{projectRoot}/generated-types"
      ]
    },
    "test": {
      "cache": true,
      "inputs": ["default", "^production"]
    },
    "lint": {
      "cache": true,
      "inputs": ["default"]
    }
  }
}

An nx.json where build hashes production files plus the API_URL env var, the Node version and a workspace-level config file, and declares both output directories; test and lint keep default because specs and lint config are real inputs to them.

Making CI and local hashes agree for remote caching

A remote cache only pays off when a CI machine and a developer laptop compute the same hash for the same commit. Most disagreements come from inputs that describe the machine rather than the source.

Start by pinning tool versions in the repository: the Node version, the package manager version, and a committed lockfile installed in frozen mode in CI. Next, normalize any environment variable declared as an input. If CI sets NODE_ENV=production and laptops leave it unset, every task that reads it gets two cache keys. Remove machine-specific runtime inputs entirely, such as an OS name, an absolute path or a hostname. These are unique to each machine, so no two machines can share an entry.

To verify, check out the same commit locally and in CI, run the same target, and diff the two hash detail outputs. Any line that differs is the cause, whether it is a file, an env value or a runtime command result.

Input type Benefit Cost
Env var Keys stay correct when config changes output Each distinct value fragments the shared cache
Runtime command Captures tool versions that files miss Adds hashing time; unstable output causes misses
Pinned files only Stable, shared keys A tool drift that is not pinned can produce a false hit

Platform teams that run a shared cache for many contributors gain the most from this work. Small teams can rely on the local cache only. For any team, targets that are cheap to run or that are hard to make deterministic can simply be marked as not cacheable.

Cost, engineering time and a review checklist for new targets

left uncached, which is the alternative this section closes with.

Where the time goes

A cache miss costs the task's full runtime, multiplied by every developer and CI job that hits it. A false cache hit costs much more: a stale artifact passes review, and someone spends hours finding out why. Tuning inputs should therefore favor correctness first and hit rate second.

Illustrative estimate (assumed numbers, not a benchmark): wasted time per week = misses per week × average task duration. Assume 40 avoidable misses a day, 3 minutes per task, 5 working days. That gives 40 × 3 × 5 = 600 minutes, or 10 hours of compute and waiting each week. Replace these numbers with your own before deciding how much effort the fix deserves.

Maintenance cost

Every project-level inputs override is a fork of the shared defaults. When targetDefaults or a plugin's inferred inputs change, overridden projects do not pick up the change, and they drift without anyone noticing. Inferred defaults can also change when you upgrade Nx or a plugin. After an upgrade, run nx show project on a few representative projects and diff the resolved inputs against what they were before.

Review checklist for a new target

  • Files read: is each one covered by inputs?
  • Environment variables read: is each one declared as an env input?
  • Tools shelled out to: is the tool's version part of the hash, through a runtime input or the lockfile?
  • Files written: is each one listed in outputs?
  • Worth caching? If the task is nondeterministic or finishes in a second or two, set cache: false and skip the analysis.

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