How does Nx compute a task hash, and what counts as an nx cache miss?

Before running a cacheable target, Nx computes a hash from everything that is supposed to determine the task's result. The main contributors are:

  • File sets resolved from inputs and namedInputs. default usually means all files in the project plus sharedGlobals. production is typically default minus test files and specs. sharedGlobals covers workspace-wide files such as a root config. A ^ prefix, as in ^production, pulls in the matching files of the projects this one depends on.
  • Env inputs: the values of named environment variables.
  • Runtime inputs: the output of a command, such as a Node version check.
  • External dependencies: package versions resolved from the lockfile.
  • Upstream results: hashes of dependent tasks, or their output files when you use dependentTasksOutputFiles.
  • The task itself: target name, options, and the command being run.

Inputs → hash → lookup file sets + env + runtime + external deps + upstream hashes + task config → combined into one task hash → look up in local cache, then remote cache if configured → hit: restore outputs and logs, skip execution → miss: run the task, then store outputs under that hash

The hash works as a content address. A miss means Nx found no stored entry for this exact hash, so at least one input differed from every previous run. Nx does not misread the result. The question to answer is always which input changed.

The goal of caching is to avoid repeating work. Builds and test suites that already ran against identical inputs should cost nothing in CI minutes or developer wait time. A backend project that misses on every run gets none of that benefit and still pays the hashing overhead.

+-- task inputs ------------------------------------------------------------+
| +---------------------+  +---------------------+  +---------------------+ |
| | File sets           |  | Env inputs          |  | Runtime inputs      | |
| | default, production |  | values of named     |  | output of a command | |
| | sharedGlobals       |  | env variables       |  | e.g. node -v        | |
| | ^: deps' file sets  |  |                     |  |                     | |
| +---------------------+  +---------------------+  +---------------------+ |
| +---------------------+  +---------------------+  +---------------------+ |
| | External deps       |  | Upstream results    |  | Task config         | |
| | package versions    |  | dependent task      |  | target name,        | |
| | from the lockfile   |  | hashes or output    |  | options, command    | |
| |                     |  | files               |  |                     | |
| +---------------------+  +---------------------+  +---------------------+ |
+-------------------------------------+-------------------------------------+
                                      v
                  +---------------------------------------+
                  | combined into one TASK HASH           |
                  +---------------------------------------+
                                      v
                  +---------------------------------------+
                  | look up: local cache, then            |
                  | remote cache if configured            |
                  +-------------------+-------------------+
        +-----------------------------+-----------------------------+
        v hit                                                       v miss
+---------------------------------+       +---------------------------------+
| HIT: restore outputs and logs   |       | MISS: run the task              |
| skip execution                  |       | store outputs under the hash    |
+---------------------------------+       +---------------------------------+

Every input to a cacheable Nx task is folded into a single task hash that is looked up in the local cache and then the remote cache, so a miss means at least one input differed from all stored runs.

Is it really a cache miss? Three problems that look the same

…for the hashing work anyway. Before changing any inputs, confirm which failure you actually have. Three different problems produce the same complaint ("it never caches"), and each has a different fix.

Case Symptom How to confirm Fix
Target not cacheable Every run executes the command, and Nx never reports reading from cache Run nx show project <name> and check whether the target is marked cacheable Enable caching for the target in targetDefaults in nx.json or in project.json
Hash changes every run Target is cacheable, but two back-to-back runs with no edits both execute Compare the task hash across two runs. If it differs, an input is unstable Find the changing input (sections 3–4) and exclude or stabilise it
Hit with wrong outputs Nx reports a cache hit, but dist/ is empty or stale and a later step fails or rebuilds Delete the build folder, rerun, and check what was restored Make outputs match the paths the tool actually writes

The third case is the easiest to miss. Nx restores only the paths listed in outputs. If the compiler writes somewhere else, the hit replays terminal logs and nothing else, so a Docker build or deploy step downstream fails or rebuilds from scratch.

The engineers who own nx.json and the shared target defaults should do this triage, usually platform or backend leads. It is worth the effort when a target runs on most CI jobs or takes more than a few seconds locally. A rarely run script that finishes in two seconds can stay as it is.

How do you see what Nx thinks a task's inputs are?

Before changing any configuration, check what Nx has actually resolved for the target. The project.json file is only one layer. targetDefaults in nx.json, named inputs such as default or production, and targets inferred by plugins are all merged into the final definition. The inputs that matter are the merged ones, and they are often broader than the file suggests.

Run nx show project <name> to print the resolved project configuration. Add --web to open the same data in a browser view. For each target, look at three things:

  • Inputs: whether named inputs expand into file globs you did not expect, such as a pattern that pulls in test fixtures or generated files.
  • Dependency inputs: whether entries prefixed with ^ pull in inputs from upstream projects.
  • Outputs: whether the declared outputs match where the build writes. A mismatch here causes problems that look like caching failures, such as missing or stale artifacts after a cache hit, even when the hash is stable.

For a single task, open nx graph and switch to the task view. Selecting a task shows its dependencies and, depending on your Nx version, its resolved inputs. This view helps when an input from a dependency project is the suspect, because you can trace the edge that brings it in.

The sample target below declares narrow production inputs and an explicit output directory. Use it as a reference shape when comparing your resolved output.

Hash-detail output and the flags that expose it have changed across Nx releases. Check the documentation for your installed version rather than relying on examples written for another release.

{
  "name": "api",
  "sourceRoot": "apps/api/src",
  "projectType": "application",
  "namedInputs": {
    "production": [
      "{projectRoot}/src/**/*.ts",
      "{projectRoot}/tsconfig.app.json",
      "!{projectRoot}/src/**/*.spec.ts",
      "!{projectRoot}/src/**/__fixtures__/**"
    ]
  },
  "targets": {
    "build": {
      "executor": "nx:run-commands",
      "cache": true,
      "inputs": [
        "production",
        "^production",
        { "externalDependencies": ["typescript"] }
      ],
      "outputs": ["{workspaceRoot}/dist/apps/api"],
      "options": {
        "command": "tsc -p apps/api/tsconfig.app.json --outDir dist/apps/api"
      }
    }
  }
}

A reference build target with narrow production inputs, upstream ^production inputs and an output path that matches tsc's --outDir. Compare it with what nx show project api --web reports after merging.

How do you find which input changed between two runs?

The loop is simple. Run the task once to populate the cache, then run it again immediately without touching anything. If the second run misses, something in the inputs is not stable, and you can isolate it without guessing. Capture the hash details for both runs and save them to files. Also set NX_VERBOSE_LOGGING=true so Nx logs more about how it resolves tasks and the cache. Then diff the two captures. The diff will usually point to one of three things:

  • A file hash that changed, often a generated file, a lockfile rewritten by an install step, or a build artifact sitting inside a project root.
  • An environment variable whose value differs per run, such as a timestamp, a random port or a build ID.
  • A runtime input, meaning a command's output that is captured as an input and is not deterministic.

If the task's own inputs look identical, the change came from upstream. Inputs that use ^ and dependentTasksOutputFiles pull in the hashes of dependency tasks or the contents of their outputs. One non-deterministic build can therefore invalidate every task downstream of it. A typical cause is a bundler that embeds the current time. Walk the dependency chain from the bottom up and compare each task's hash across the two runs. The first task whose hash changed is the one to fix. Everything above it is a symptom.

The same method works when a task hits the remote cache on your laptop but misses in CI. Capture hash details on both machines and diff them. The differences are usually things that vary by machine: the OS or architecture, the runtime version, environment variables that only CI sets, and absolute paths or line endings that leak into file contents.

#!/usr/bin/env bash
# Debug loop for an unstable task hash.
# Usage: ./diff-hash-details.sh before.json after.json
# Each JSON file holds the hash details captured for one run of the same task:
# two local runs, or a laptop run and a CI run. Expected shape is roughly
# {"command": "...", "nodes": {...}, "runtime": {...}}.
set -euo pipefail

# Step 1 (run separately): execute the task twice with no changes in between.
# Verbose logging adds context about task resolution and cache lookups.
#   NX_VERBOSE_LOGGING=true npx nx run api:build > run1.log 2>&1
#   NX_VERBOSE_LOGGING=true npx nx run api:build > run2.log 2>&1

before="$1"
after="$2"

# Flatten nested JSON into sorted "key.path<TAB>value" lines.
# A changed file hash, env var or runtime value then shows up as a single diff line.
flatten() {
  jq -r 'paths(scalars) as $p
    | [($p | map(tostring) | join(".")), (getpath($p) | tostring)]
    | @tsv' "$1" | sort
}

# Step 2: diff the two captures. Lines starting with < or > are the unstable inputs.
# If only upstream entries differ (^ inputs or dependency outputs), rerun this on the
# dependency's hash details. Keep walking down until you reach the first task whose
# own inputs changed.
if diff <(flatten "$before") <(flatten "$after"); then
  echo "No differences: this task's inputs are stable between runs"
fi

A shell script that flattens two captured Nx hash-detail files with jq and diffs them line by line, so the file, environment variable or runtime value that changed between two runs, or between a laptop and CI, appears as a single differing entry.

What usually breaks caching on backend projects?

Once the cross-machine noise is ruled out, the remaining misses tend to come from a short list of causes. Most of them come from Nx's default inputs, which hash every file under the project root and some workspace-level files. Nx does not know that a file was produced by the build itself.

Level Culprit Why it misses Detection hint
Project Generated ORM clients, protobuf/gRPC stubs, OpenAPI clients in the project root Regenerated with new timestamps or ordering on every run Changed paths sit under a generated or client folder
Project Build output or coverage written inside {projectRoot} The task's own outputs become its next inputs Every run misses once, then hits
Project .env, local config, logs, tmp dirs, SQLite or test DB files Written by running the app or tests Untracked files in git status
Workspace sharedGlobals and {workspaceRoot} globs One root edit invalidates every project All projects miss at the same time
Workspace Lockfile churn, tsconfig.base.json edits Treated as relevant to every project Diff shows only the lockfile or base config
Workspace Broad implicit dependencies Unrelated projects pull each other in Project graph shows unexpected edges
Environment Per-run env vars such as build numbers, SHAs, timestamps The value differs on every run by design Env inputs list CI-only variables
Environment Runtime inputs with machine-specific output The command's output varies by host Hits locally, misses in CI, or the reverse

Some causes cannot be fixed in Nx config. A code generator that writes a timestamp header, or a bundler that embeds absolute paths, has to be made deterministic through its own settings. Excluding those files from the inputs only hides the problem.

How do you narrow task inputs without causing false cache hits?

Once the tools themselves are deterministic, the remaining work is telling Nx exactly what each target reads. Start with a production named input defined as default minus negated globs such as !{projectRoot}/**/*.spec.ts, !{projectRoot}/test/**, and !{projectRoot}/**/*.local.*. Point build at ^production and production so that editing a test in a dependency does not invalidate every downstream build.

Use .gitignore and .nxignore deliberately. Nx skips files ignored by either one when hashing, so a scratch .env.local or a coverage folder should be ignored rather than handled with negated globs in every project. The danger runs the other way too. If a file the build actually reads is ignored, edits to it will never change the hash.

Generated code deserves its own target. Make the schema (OpenAPI spec, Prisma schema, protobuf files) that target's input, declare the generated directory as its output, and have build depend on it. The generator's result is then restored from cache instead of being rewritten with a new mtime or header on every run.

For env inputs, list only variables that change the artifact, such as a build-time feature flag. Leave out CI, HOME, and tokens.

Each of these narrowing steps has a cost. If you exclude something the compiler does read, Nx restores a stale artifact, and that kind of false cache hit is harder to detect than a miss. Check every exclusion against the tool's own include list, such as the tsconfig include and exclude settings. Runtime inputs like node --version improve correctness, but they split the cache when CI and developer machines differ.

// nx.json (Nx parses this file as JSON with comments)
{
  "namedInputs": {
    "sharedGlobals": [],
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    // Leave out files the compiler never reads. Check each glob against tsconfig include/exclude.
    "production": [
      "default",
      "!{projectRoot}/**/*.spec.ts",
      "!{projectRoot}/test/**/*",
      "!{projectRoot}/**/*.local.*",
      "!{projectRoot}/jest.config.ts"
    ]
  },
  "targetDefaults": {
    // Only the schema drives codegen, so cache the generated directory as an output.
    "generate-client": {
      "cache": true,
      "inputs": ["{projectRoot}/openapi.yaml"],
      "outputs": ["{projectRoot}/src/generated"]
    },
    "build": {
      "cache": true,
      "dependsOn": ["^build", "generate-client"],
      "inputs": [
        "production",
        "^production",
        // Include only env vars that change the artifact. Leave out CI, HOME and tokens.
        { "env": "FEATURE_BILLING_V2" },
        // Better correctness, but CI and laptops on different Node versions will not share cache entries.
        { "runtime": "node --version" }
      ],
      "outputs": ["{options.outputPath}"]
    }
  }
}

An nx.json with a production named input that excludes tests and local files, a cached codegen target keyed on its schema, and build inputs limited to one env var and the Node runtime.

When should you avoid caching, and what are the alternatives to fixing inputs?

Not every target should be cached. A task that finishes faster than Nx can hash its inputs and restore outputs gains nothing from caching, and every extra cached artifact adds storage and noise. Side-effecting tasks are worse: a cached database migration, deploy, or integration test against a live service can report success without touching the system it was supposed to change or check. For these targets, set cache to false so a hit is never possible.

For everything else, the quick fixes are tempting but trade away information:

Option What it does Good for Cost
nx reset Clears the local cache and daemon state Recovering from a corrupted or stale cache Deletes the evidence you'd use to compare hashes
--skip-nx-cache Ignores cache reads for one run Confirming a suspected false hit Hides the miss; the cause persists
cache: false on the target Never caches that target Fast or side-effecting tasks Pays full runtime on every run
Accept misses Leave non-deterministic tools uncached-in-practice Tools that embed timestamps or random IDs Wasted hashing and upload on every run

Reset and skip make the symptom go away. They don't tell you which input moved, and after a reset, there's nothing left to diff.

Illustrative estimate (assumed inputs): a backend build takes 4 minutes, misses on 30 CI runs a day, and fixing its inputs takes one engineer-day (480 minutes). The misses cost 120 CI minutes daily, so the fix pays for itself in about four working days. After that, it keeps saving time on every run.

How do you verify the fix and keep it from regressing?

Verify with three runs

Start from a clean checkout and an empty local cache (nx reset clears it), so leftover artifacts cannot hide the problem. Run the target once to populate the cache, then run it again without changing anything. The second run must be a hit. If it misses, an input is still nondeterministic, such as a generated file, a timestamp, or an environment variable that differs per process. Rerun the comparison from the earlier sections before going further.

Next, edit a file the task should not depend on, such as a README or a test fixture belonging to another project, and run again. It should still hit. This shows your inputs are not too broad. Then change a file the task really depends on, such as a source file or a dependency's public module, and run again. It must miss. This step matters most, because it rules out the false cache hits that over-narrowed inputs cause. A config that passes the first two checks but fails this one is worse than one that never caches.

Guard critical targets in CI

Add a small CI job for the few targets that cost the most, such as the backend build or the integration test setup. Disable remote caching for this job, then run the target twice in the same workspace. Fail the job if the second run was not served from the cache. Checking Nx's per-task cache status is more reliable than timing the run. Because this job runs on every change to project configuration, a new unstable input breaks a visible check right away. Without it, the regression would show up weeks later as slower pipelines that nobody can explain.

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.