How Nx computes a task hash and why the default inputs cause cache misses

Before Nx runs a cacheable task, it computes a hash from everything that could plausibly change the task's output. If that hash matches a stored entry, locally or in a remote cache, Nx replays the saved terminal output and restores the output files instead of running the task. The hash is built from several categories of input:

  • File contents matched by the target's inputs, read from the project's own files.
  • Dependency hashes for projects in the graph that this project depends on, so a change in a shared library flows into its consumers.
  • The command and its options, including the executor and the target configuration.
  • Runtime and environment inputs you declare, such as the output of a version command or the value of an environment variable.
  • Global files that affect every project, such as workspace-level configuration.

The problem is the fallback. If a target doesn't declare inputs, Nx hashes every file in the project and treats changes in dependency projects the same way. A test file, a README, a story file and a lint config all feed the build hash. Editing one assertion in a spec therefore invalidates build, test and lint for that project and for every project that depends on it. The compiled output is identical, but the cache can't know that.

Platform and backend engineers feel this most. They run many cached targets across many projects, often in CI with a shared remote cache. In that setting, each spurious miss turns into rebuild time on every affected pipeline.

 +--------------------+  +--------------------+  +---------------------+
 | Project files      |  | Dependency project |  | Command + options   |
 | (matched by inputs)|  | hashes (^ deps)    |  | executor, config    |
 +---------+----------+  +---------+----------+  +----------+----------+
           |                       |                        |
 +---------+----------+            |             +----------+----------+
 | Runtime / env      |            |             | Global files        |
 | inputs (declared)  |            |             | (workspace config)  |
 +---------+----------+            |             +----------+----------+
           |                       v                        |
           +--------------> +-------------+ <---------------+
                            |  Task hash  |
                            +------+------+
                                   |
                                   v
                       +-----------------------+
                       | Local / remote cache  |
                       +-----+-----------+-----+
                             |           |
                         hit v           v miss
              +---------------+       +---------------+
              | Replay output |       | Run the task  |
              | restore files |       | store result  |
              +---------------+       +---------------+

 Default inputs (none declared): every project file feeds every target
 +-----------------------+      +-------+ +------+ +------+
 | edit foo.spec.ts      |----->| build | | test | | lint | --> all miss,
 | (all files are input) |      +-------+ +------+ +------+     dependents too
 +-----------------------+

Nx combines file, dependency, command, runtime/env and global inputs into one task hash, so under the default all-project-files inputs a single spec edit changes the hash for build, test and lint in that project and its dependents.

Anatomy of namedInputs: tokens, patterns and exclusions

A named input is a reusable, named list of inputs defined under the namedInputs key in nx.json. Targets then refer to the name instead of repeating the same patterns in every inputs array. This matters for cache correctness because the hash is only as precise as its inputs. If every target writes its own glob list, the lists drift apart, and a single sloppy entry can make a build depend on files it never reads.

Each entry in a named input is one of a small set of things. It can be a file pattern, the name of another named input, or a non-file input such as an environment variable or a runtime command. File patterns are anchored with one of two tokens:

  • {projectRoot} expands to the directory of the project that owns the task. {projectRoot}/**/* therefore means every file in that project.
  • {workspaceRoot} expands to the repository root. It is used for shared files such as a root tsconfig.base.json or a lint config.

Always use one of the tokens rather than a bare relative path. The tokens make it unambiguous whether a file belongs to one project or to the whole workspace.

A leading ! turns a pattern into an exclusion. For example, !{projectRoot}/**/*.spec.ts removes test files from whatever the other entries matched.

Named inputs compose by name. production can list default and then subtract tests and docs, so you define the broad set once and narrow it where needed. Prefixing a name with ^, as in ^production, applies that named input to the project's dependencies instead of to the project itself.

{
  "namedInputs": {
    "sharedGlobals": [
      "{workspaceRoot}/tsconfig.base.json"
    ],
    "default": [
      "{projectRoot}/**/*",
      "sharedGlobals"
    ],
    "production": [
      "default",
      "!{projectRoot}/**/*.spec.ts",
      "!{projectRoot}/jest.config.ts",
      "!{projectRoot}/**/*.md"
    ]
  },
  "targetDefaults": {
    "build": {
      "cache": true,
      "inputs": ["production", "^production"]
    },
    "test": {
      "cache": true,
      "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"]
    }
  }
}

An nx.json fragment where production reuses default and excludes specs, Jest config and Markdown, so build hashes ignore test and doc edits while test still hashes them; ^production applies the same set to dependencies.

Building the default, production and sharedGlobals trio step by step

Start in nx.json. Define default as {projectRoot}/**/* plus sharedGlobals, so every file in a project counts for something. Define production as default followed by negated patterns: !{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x), !{projectRoot}/jest.config.[jt]s, !{projectRoot}/tsconfig.spec.json and !{projectRoot}/**/*.md. Exclusions only subtract from what earlier entries added, so keep them after default. Set sharedGlobals to workspace files that should invalidate every task, such as a CI workflow file or a root babel.config.json.

Then wire targetDefaults:

Target Inputs Rationale
build production, ^production Artifacts depend only on shipped source, both local and upstream
test default, ^production A spec edit must rerun its own tests, but a dependency's specs cannot affect them
lint default, ^production Linting covers test files and configs, so they must be in the hash

The caret is what keeps this cheap. ^production hashes the production files of every project this one depends on. Editing a test in a shared library reruns that library's tests and nothing downstream. Dropping default from test or lint would be a correctness bug, because a spec change would replay a stale pass.

Files are not the only thing that can change an output. Add { "runtime": "node -v" } when the toolchain version matters. Add { "env": "API_URL" } for any variable that gets inlined at build time. This matters most with a shared remote cache. Two machines with different Node versions or different environment values would otherwise produce the same hash, and one of them would be served the other's artifact.

{
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": [
      "default",
      "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)",
      "!{projectRoot}/jest.config.[jt]s",
      "!{projectRoot}/tsconfig.spec.json",
      "!{projectRoot}/**/*.md"
    ],
    "sharedGlobals": [
      "{workspaceRoot}/.github/workflows/ci.yml",
      "{workspaceRoot}/babel.config.json"
    ]
  },
  "targetDefaults": {
    "build": {
      "inputs": [
        "production",
        "^production",
        { "runtime": "node -v" },
        { "env": "API_URL" }
      ]
    },
    "test": {
      "inputs": ["default", "^production"]
    },
    "lint": {
      "inputs": ["default", "^production"]
    }
  }
}

An nx.json defining default, production and sharedGlobals, with targetDefaults where build hashes shipped files plus Node version and API_URL, while test and lint hash their own full project files.

Choosing what belongs in sharedGlobals

The opposite risk applies to files: whatever you put in sharedGlobals becomes an input to every task that references it. The test for each candidate is whether a change to this file can change the output of a build in a project that does not otherwise reference it. If yes, include it. If it only sometimes can, look for a narrower input.

Candidate Include? Reasoning
tsconfig.base.json Yes Compiler options and path aliases change emitted code everywhere
Whole lockfile Usually no Nx already hashes the external packages each project depends on through the project graph
CI workflow files Only specific ones Most edits change scheduling, not artifacts
Root README, .github templates No No effect on build output
Root babel.config.json or shared bundler presets Yes, if projects consume them They act as compiler config

Too little shows up as stale artifacts. Someone changes target in the base tsconfig, the cache returns the old output, and nothing fails until runtime. These bugs are hard to trace because the hash looks legitimate.

Too much shows up as a hit rate that never improves. A glob like {workspaceRoot}/*, or the full lockfile, changes on almost every merge. A dependency bump in one app then invalidates every library.

When a CI file sets something that does affect builds, such as a build flag or environment value, move that value into a runtime or env input. Do not hash the whole YAML file.

{
  "namedInputs": {
    "sharedGlobals": [
      "{workspaceRoot}/tsconfig.base.json",
      "{workspaceRoot}/babel.config.json",
      { "env": "BUILD_MODE" },
      { "runtime": "node --version" }
    ],
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": [
      "default",
      "!{projectRoot}/**/*.spec.ts",
      "!{projectRoot}/**/*.md"
    ]
  },
  "targetDefaults": {
    "build": {
      "inputs": ["production", "^production"],
      "cache": true
    }
  }
}

An nx.json where sharedGlobals lists only files that change compiler output, plus the env value and Node version CI sets, instead of root globs, the full lockfile or whole workflow YAML files.

Verifying the setup instead of trusting it

A hand-written exclusion list is a hypothesis until a cache run proves it. The check below takes a few minutes and can be repeated after every change to namedInputs.

A repeatable check

  1. Baseline. On a clean working tree, run nx run-many -t build test twice. The second run should report every task as a cache hit. If it does not, something nondeterministic is already in the inputs, such as a generated file or a timestamp. Fix that first, or the remaining steps will tell you nothing.
  2. Test-only change. Edit a single spec file in one library and run both targets again. build for that project and its dependents should come from cache. test should re-execute.
  3. Docs-only change. Revert, then edit a README or a Markdown file under docs/. build should hit the cache. Whether test hits depends on whether your default input includes docs, so confirm the result matches the decision you made.
  4. Control run. Repeat one scenario with --skip-nx-cache. Every task now executes. If the build output is identical to the cached artifact, the excluded files really did not affect the result.

When a task misses unexpectedly, open nx graph or nx show project <name> --web and look at the target's resolved inputs. Find out which file pattern or runtime value pulled the task in. Do not guess.

Expect one full cache miss right after rollout, because every hash changes. After that first run, the cache settles. Nx will not warn you if an exclusion is wrong. A pattern that drops a file the build actually reads will produce stale cache hits without any error, and only the control run in step 4 will catch it.

When to avoid aggressive exclusions and when to use per-project overrides

The riskiest exclusions are the ones that look obviously safe. A !{projectRoot}/**/*.md pattern is harmless for an API service, but it breaks a docs site whose build imports markdown through a loader. Then content edits ship without a rebuild. JSON fixtures cause the same problem when a "fixtures" folder doubles as seed data the service reads at runtime. A .d.ts file sitting next to specs may declare types that production code depends on. Images referenced from markdown get swept out along with the docs that point to them.

Conventions are fragile too. **/*.spec.ts catches nothing in a project that keeps tests in __tests__/ directories or a sibling e2e/ folder. Those files then stay in the production hash and keep causing misses. You have two options. You can standardize test layout across the workspace so one set of patterns in nx.json is accurate everywhere. Or you can accept the variation and let outliers override. Standardizing is cheaper to reason about. Overrides suit legitimate exceptions, not inconsistency nobody has cleaned up yet.

To override, a project redefines production in its own project.json. That definition takes precedence over the workspace one for that project. For a docs site, the override keeps default and the spec exclusions but deliberately leaves out the markdown and asset negations, because those files are the product.

Audit checklist

  • Does any loader, import, or bundler config read this file type?
  • Is the file copied, served, or read at runtime?
  • Do type declarations or generated code live in the excluded paths?
  • Do any non-excluded files reference these files?

If a file can influence emitted output, it must not be excluded.

Alternatives and impact on cost and engineering time

The production-input pattern is one of several ways to deal with needless rebuilds, and it is not always the cheapest option.

Approach Setup cost Ongoing cost Effect on test/doc-only changes
Leave default inputs None None Every edit rebuilds the project
default + production + sharedGlobals A few hours, plus one full cache miss when hashes change Review globs when the folder layout changes Builds keep their cache hits
Hand-written inputs on every target High Drift across many project files Same benefit, more places to get it wrong
Disable caching for problem targets Minutes Every run pays full cost No cache hits at all
Affected-only runs, no tuning Low None A spec edit still marks the project affected, and its build still misses

Affected-only runs work well alongside input tuning, but they do not replace it. They skip projects nobody touched. They cannot tell which files inside a touched project actually matter to the build.

Illustrative estimate (assumed numbers, not a benchmark)

Monthly CI minutes saved = test/doc-only PRs per month × avoidable build minutes per PR.

Assume 25 such PRs per day over 20 working days, with 6 minutes of build per PR. That gives 500 × 6 = 3,000 minutes, or 50 hours of CI time per month. Set that against an assumed 8 engineer-hours for setup and verification, plus about 1 hour per month of glob maintenance. Use your own CI rates and engineer cost to compare the two.

The change pays off when builds are slow and edits to tests or docs are frequent. A workspace with a handful of projects and builds under a minute will see little benefit beyond the default inputs.

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