A remote cache only pays off when the cache keys are right. Nx computes those keys from the inputs and outputs you declare, so 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.
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": [
"{workspaceRoot}/.github/workflows/ci.yml",
{ "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 - things that affect every project, like the CI workflow and the Node version. When these change, all caches should invalidate.
- default - all project files minus generated outputs (coverage, reports) and docs. Used by
testandlint. - prodFiles - production sources only, with tests and tooling configs excluded. Used by
build.
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
prodFilesfor build inputs, setcache: trueon 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
^defaultfor build dependencies - test-file edits will invalidate builds. - Don't put
node_modulesin 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 resetMost 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.
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": [
"{workspaceRoot}/.github/workflows/ci.yml",
{ "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.