← All posts
8 min readThe Cachely team

Nx cache not working? Fix cache misses with inputs and outputs

When the Nx cache seems not to work - every run is a cache miss, or a hit restores nothing - the cache is almost never broken; its configuration is. A remote cache only pays off when the cache keys are right. Nx hashes task inputs to decide whether work can be reused; outputs describe the files to store and restore. A sloppy nx.json quietly turns a fast monorepo into a slow one - too-broad inputs invalidate everything on every change, and missing outputs mean a hit restores nothing. Here is a practical guide to configuring Nx caching well, the patterns that move the needle most, and the mistakes worth avoiding.

Inputs and outputs, briefly

Nx decides whether a task can be served from cache by hashing its inputs - the files and values that affect the result. Change an input and the hash changes, so the cache is correctly invalidated. Outputs are what the task produces; Nx stores them under that hash and restores them on a hit. Both halves matter: get inputs wrong and you ship stale (or needlessly rebuilt) artifacts; forget outputs and a cache hit gives you nothing back.

{
  "inputs": [
    "{projectRoot}/**/*",        // all project files
    "{workspaceRoot}/nx.json",   // workspace config
    { "runtime": "node -v" },    // Node.js version
    { "env": "CI" }              // an environment variable
  ],
  "outputs": [
    "{options.outputPath}",      // build artifacts
    "{projectRoot}/coverage"     // test coverage
  ]
}

This is the same principle behind any correct cache: key on exactly what affects the output and nothing more. We unpack that idea in what makes a good build cache; this post is the Nx-specific how-to.

Check one target before changing the whole workspace

Inspect the resolved target, including plugin-inferred settings. Replace my-app and build with a real project and cacheable target. In a disposable checkout, use the same command twice with remote caching excluded so local configuration is the only cache layer being tested:

npx nx show project my-app --json
npx nx run my-app:build --skipRemoteCache
npx nx run my-app:build --skipRemoteCache
  • The unchanged second run should reuse the result. If it executes again, inspect runtime inputs, environment values, and generated files inside input globs.
  • Change a source file used by the build and rerun: it should execute. A hit suggests an input is missing.
  • Change only a test file that the production build does not consume: a correctly scoped production input set should still allow reuse.
  • After seeding, remove only the generated build output in the disposable checkout and rerun unchanged: a hit should restore it. Otherwise inspect outputs.

Read Nx's cache model and input configuration guidance. Keep conservative inputs until an experiment proves an exclusion is safe. After local correctness is established, use the Nx remote cache setup and fresh-runner benchmark to verify sharing across machines.

Named inputs are the foundation

Named inputs let you define reusable groups of input patterns once and reference them across every target. This is the single most impactful piece of cache configuration, because it lets you draw a clean line between "files that affect a build" and "files that only affect tests or lint". A good starting point:

{
  "namedInputs": {
    "sharedGlobals": [
      { "runtime": "node -v" }
    ],
    "default": [
      "sharedGlobals",
      "{projectRoot}/**/*",
      "!{projectRoot}/coverage",
      "!{projectRoot}/reports",
      "!{projectRoot}/*.md"
    ],
    "prodFiles": [
      "default",
      "!{projectRoot}/**/*.spec.ts",
      "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
      "!{projectRoot}/tsconfig.spec.json",
      "!{projectRoot}/eslint.config.*"
    ]
  }
}

Three groups, each with a clear job:

  • sharedGlobals - shared values that affect these tasks, such as the Node version. Avoid hashing an entire CI workflow unless its contents affect outputs; changing a job name or timeout should not invalidate every build.
  • default - all project files minus generated outputs (coverage, reports) and docs. Used by test and lint.
  • prodFiles - production sources only, with tests and tooling configs excluded. Used by build.

These examples assume production builds do not consume test files, Markdown, or the excluded configurations. Documentation builds and tests that import shared test helpers need different inputs. Keep the defaults supplied by your Nx plugin unless you can explain and verify each override.

The one pattern that matters most: ^prodFiles for builds

When a build target depends on other projects, its inputs should reference those dependencies' production files, not all of their files. In Nx the ^ prefix means "this input, taken from dependent projects". So the correct build configuration is:

{
  "targetDefaults": {
    "@nx/esbuild:esbuild": {
      "cache": true,
      "inputs": ["prodFiles", "^prodFiles"],
      "outputs": ["{options.outputPath}"]
    }
  }
}

Compare that to the common mistake of using default and ^default for a build. Because default includes test files, editing a single spec in a shared library would invalidate the build cache of every downstream app that depends on it - even though the shipped output is identical. Switching dependency inputs to ^prodFiles means a library's test changes never ripple into downstream builds, which is one of the largest hit-rate wins available in a typical monorepo.

Target configuration by type

Build

Use prodFiles for the project's own sources and ^prodFiles for its dependencies, and always declare the output path so the artifact can actually be restored.

{
  "@nx/webpack:webpack": {
    "cache": true,
    "inputs": ["prodFiles", "^prodFiles"],
    "outputs": ["{options.outputPath}"]
  }
}

Test

Tests legitimately depend on test files, so they use default for the project plus ^prodFiles for dependencies (a dependency's tests do not affect your tests). Pull in shared test config, and list every output directory.

{
  "test": {
    "cache": true,
    "inputs": [
      "default",
      "^prodFiles",
      "{workspaceRoot}/jest.preset.*",
      { "env": "CI" }
    ],
    "outputs": [
      "{projectRoot}/coverage",
      "{workspaceRoot}/reports/{projectName}.xml"
    ]
  }
}

Lint

Lint reads the project files plus the shared lint and formatter config, so those configs belong in its inputs - otherwise tweaking a rule will not invalidate stale lint results.

{
  "lint": {
    "cache": true,
    "inputs": [
      "default",
      "{workspaceRoot}/eslint.config.*",
      "{workspaceRoot}/.prettierrc*"
    ],
    "outputs": ["{options.outputFile}"]
  }
}

Quick wins

  • Add the Node version to sharedGlobals. { "runtime": "node -v" } invalidates caches when Node changes - important when a runtime bump alters compiled output.
  • Exclude generated files from default. Coverage, reports, and README changes should never invalidate a test or lint cache; they are outputs, not inputs.
  • Use ^prodFiles for build dependencies. The biggest single hit-rate improvement in most workspaces.
  • Put CI config in sharedGlobals. A change to the workflow that runs your tasks should bust the caches those tasks produced.
  • Always declare outputs. A hit with no declared outputs restores nothing, so the "cached" task still effectively re-runs.

Do's and don'ts

  • Do use prodFiles for build inputs, set cache: true on cacheable targets, and enumerate every output directory.
  • Do include shared configs (eslint.config.*, jest.preset.*) in the inputs of the tasks they affect.
  • Don't use ^default for build dependencies - test-file edits will invalidate builds.
  • Don't put node_modules in inputs (a massive, pointless hash) or cache non-deterministic targets (it leads to inconsistent results - determinism comes first).

Debugging cache misses

When a task you expected to hit instead rebuilds, a few commands narrow it down fast:

# See how projects relate, to reason about ^ inputs
npx nx graph

# Bypass the cache to confirm the task itself is fine
npx nx build my-project --skip-nx-cache

# Clear local cache state entirely
npx nx reset

Most surprise misses trace back to an over-broad input (an absolute path, a timestamped file, or a stray generated artifact sneaking into default) or a missing exclusion. Tighten the named input, re-run, and the hit rate recovers. Clearing the cache will not fix a miss; if you do need to reset or skip it, the flags are in how to clear, skip, or reset the Nx cache.

A complete starting point

Putting it together, a solid baseline nx.json looks like this - adjust the executors and paths to your stack:

{
  "$schema": "./node_modules/nx/schemas/nx-schema.json",
  "parallel": 10,
  "defaultBase": "main",
  "namedInputs": {
    "sharedGlobals": [
      { "runtime": "node -v" }
    ],
    "default": [
      "sharedGlobals",
      "{projectRoot}/**/*",
      "!{projectRoot}/coverage",
      "!{projectRoot}/reports",
      "!{projectRoot}/*.md"
    ],
    "prodFiles": [
      "default",
      "!{projectRoot}/**/*.spec.ts",
      "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
      "!{projectRoot}/tsconfig.spec.json",
      "!{projectRoot}/eslint.config.*"
    ]
  },
  "targetDefaults": {
    "@nx/esbuild:esbuild": {
      "cache": true,
      "inputs": ["prodFiles", "^prodFiles"],
      "outputs": ["{options.outputPath}"]
    },
    "lint": {
      "cache": true,
      "inputs": ["default", "{workspaceRoot}/eslint.config.*", "{workspaceRoot}/.prettierrc*"]
    },
    "test": {
      "cache": true,
      "inputs": ["default", "^prodFiles", "{workspaceRoot}/jest.preset.*", { "env": "CI" }],
      "outputs": ["{projectRoot}/coverage", "{workspaceRoot}/reports/{projectName}.xml"]
    }
  }
}

Why this pairs with a remote cache

Tuning inputs and outputs raises your hit rate; a remote cache decides who gets to benefit from a hit. Get the keys right and every identical task across the team and CI maps to the same hash - but a purely local cache means each machine still rebuilds the first time it sees that hash. Share the cache and the work done once on a laptop is reused in CI, and vice versa. That is exactly what Cachely provides: a managed Nx remote cache that stores those content-addressed outputs, serves them across machines, and stays read-only by default for untrusted builds. Sharp keys plus a shared cache is where the real CI speed-up lives.

Once your config is dialled in, the CI speed checklist covers the rest of the pipeline.

Right keys, shared everywhere

A managed Nx remote cache: content-addressed, immutable artifacts, read-only by default, shared across your team and CI. Free tier - no credit card.

Read the full guideImprove cache hit rateDiagnose over-keying, env drift, unstable outputs, and missing outputs.

Related posts

Put a shared build cache behind your builds
Free tier, no credit card - connect Nx, Turborepo, Gradle, or Bazel in about five minutes.
Start freeSee pricing