How Nx sees your workspace: the project graph and task graph
Nx CI optimization starts with one data structure: the project graph. If you run a monorepo pipeline as a backend or platform engineer, every Nx feature you use to speed it up, from nx affected to the cache, reads from this graph. A wrong graph gives you wrong results.
Nx builds the graph by scanning the file system for projects. A project is a directory with a project.json file, or a package.json that your package manager's workspaces configuration includes. Nx then works out the edges between projects. It reads imports in source code, installed packages and TypeScript configuration, so most dependencies are found without you declaring them. Nx caches the graph and only re-analyzes files that changed, which keeps this step cheap on large repos.
When you run a command, Nx turns the project graph into a task graph. Each node is a target invoked on a project, such as myapp:build or api:test. Each edge is an ordering constraint, for example "build the shared library before the service that imports it."
Both main optimizations depend on this graph. affected follows edges outward from changed files to decide what to run. The cache hashes a task's inputs, and those inputs include its dependencies. If an edge is missing, Nx can skip a project that actually broke, or restore a stale result. Before you tune anything, run nx show project <name> on a few representative projects. Confirm that their targets, inputs and dependencies match what you expect.
This approach works best in multi-project repositories where most changes touch a small part of the codebase. If a single project makes up most of the code, or most commits change a shared root file, the graph has little work to remove.
#!/usr/bin/env bash
# Sanity-check the Nx project graph before tuning affected runs or caching.
# Run from the workspace root. Assumes jq is installed.
set -euo pipefail
# 1. List every project Nx discovered (project.json or workspace package.json).
# A missing project here means it is invisible to affected and the cache.
npx nx show projects
# 2. Inspect one representative project in detail.
# Check that the targets you expect exist and that inferred targets look right.
npx nx show project api --json | jq '.targets | keys'
# 3. Look at the inputs Nx will hash for the build target.
# If a config file or env var that affects the output is not covered here,
# the cache can restore a stale result.
npx nx show project api --json | jq '.targets.build.inputs'
# 4. Export the whole project graph and print the edges out of 'api'.
# Each entry should match a real import or package dependency.
# A missing edge means affected may skip 'api' when its dependency changes.
npx nx graph --file=graph.json
jq '.graph.dependencies.api' graph.json
# 5. Optional: open an interactive view focused on one project
# to eyeball its upstream and downstream neighbors.
# npx nx graph --focus=api
Shell commands that list discovered projects, inspect one project's targets and hashed inputs, and dump its dependency edges so you can confirm the graph is correct before relying on affected runs or the cache.
Running only what changed with nx affected
nx affected asks Git which files differ between a base commit and a head commit, maps each changed file to the project that owns it, and walks the project graph outward to every project that depends on those projects. It then runs the requested target only for that set. You pass the range with --base and --head, or with the NX_BASE and NX_HEAD environment variables. If you omit the base, Nx falls back to defaultBase in nx.json.
On pull-request runs, the base should be the merge base of the branch and its target branch. Comparing against the merge base limits the diff to the branch's own changes. Comparing against the target branch's tip would also pull in unrelated commits that landed after the branch was cut. On main-branch runs, the base should be the last commit whose pipeline succeeded. That way a failed run's changes are retested on the next run. The previous commit (HEAD~1) is a simpler but weaker fallback, because it forgets anything a failed run left untested.
Shallow clones break this. If the base commit was never fetched, Git cannot compute the diff. Fetch full history, or deepen the clone (git fetch --unshallow) before running Nx.
Failure modes
- Hidden dependencies. Nx cannot see code loaded at runtime, files read by path, or shared config outside any project. A broken dependent can be skipped. Declare these links with implicit dependencies or inputs.
- Root file changes. Edits to the lockfile or
nx.jsoncan mark most of the repo as affected. - Wrong base. If the base is too recent, the diff is too small and Nx silently skips work it should run.
#!/usr/bin/env bash
# Pick the affected range for a CI run, then run only affected targets.
# Expects placeholder CI variables: IS_PULL_REQUEST, TARGET_BRANCH,
# and LAST_GREEN_SHA (SHA of the last successful main pipeline, if known).
set -euo pipefail
# A shallow checkout may not contain the base commit, which would break the diff.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --unshallow origin
fi
HEAD_SHA=$(git rev-parse HEAD)
if [ "${IS_PULL_REQUEST:-false}" = "true" ]; then
# PR run: diff only the branch's own commits, measured from the merge base.
git fetch origin "+refs/heads/${TARGET_BRANCH}:refs/remotes/origin/${TARGET_BRANCH}"
BASE_SHA=$(git merge-base "origin/${TARGET_BRANCH}" HEAD)
else
# Main run: start from the last green commit, so a failed run's changes are retested.
# HEAD~1 is a weaker fallback because it forgets work a failed run left untested.
BASE_SHA=${LAST_GREEN_SHA:-$(git rev-parse HEAD~1)}
fi
# Fail loudly instead of silently diffing against a commit that is not present locally.
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
echo "Base commit ${BASE_SHA} not found locally; refusing to guess." >&2
exit 1
fi
echo "Affected range: ${BASE_SHA}..${HEAD_SHA}"
# Log the affected set so an unexpectedly small or large set is easy to spot.
npx nx show projects --affected --base="$BASE_SHA" --head="$HEAD_SHA"
# Run the targets for the changed projects and their dependents only.
npx nx affected -t lint test build --base="$BASE_SHA" --head="$HEAD_SHA"
A vendor-neutral CI script that ensures enough Git history is present, picks the merge base on pull requests and the last green commit on main, checks that the base commit exists locally, and passes the range to nx affected.
Configuring the cache: targetDefaults, outputs and inputs
Affected decides which tasks run. The cache decides which of those tasks can be skipped. To turn caching on, set cache: true for build and test under targetDefaults in nx.json.
When a target is cached unexpectedly, or not cached when you expected it to be, check the merge order. Nx takes settings inferred by plugins first, then applies targetDefaults, then applies the project's own project.json or package.json. Later sources win. So a project-level cache: false overrides your workspace default. nx show project my-project shows the final settings for each target and where each one came from.
On a cache hit, Nx restores two things: the terminal output and the files the task produced. It can only restore files from paths declared in outputs. Suppose a build writes to dist/{projectName} and that path isn't declared. The cache hit replays the logs, but dist stays empty, and the next deploy step fails. If you make the outputs too broad, every run uploads and downloads folders that nothing uses.
inputs define what goes into the cache hash. A common change is to exclude *.md, for example with !{projectRoot}/**/*.md. Then editing a README doesn't invalidate the build. Environment variables that change the build result, such as an API base URL baked into a bundle, should be added as env inputs. Put inputs in nx.json to apply them to all projects, or in project.json to apply them to one project.
Undeclared inputs are the most serious risk covered in this article. If a build reads an environment variable, a generated file or a global config file that isn't part of the hash, Nx can return a cache hit after that value has changed. It then restores old artifacts and reports success, with no error or warning.
{
"namedInputs": {
// Everything in the project plus workspace files that affect every build
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"],
// What actually ships: README edits and spec files don't change the bundle
"production": [
"default",
"!{projectRoot}/**/*.md",
"!{projectRoot}/**/*.spec.ts"
]
},
"targetDefaults": {
"build": {
"cache": true,
"dependsOn": ["^build"],
"inputs": [
"production",
"^production",
// Baked into the bundle, so a new value must produce a new hash
{ "env": "API_BASE_URL" }
],
// Declared so a cache hit restores dist, not just the logs
"outputs": ["{workspaceRoot}/dist/{projectName}"]
},
"test": {
"cache": true,
"inputs": ["default", "^production"]
}
}
}
An nx.json that turns on caching for build and test in targetDefaults. It excludes Markdown and spec files from the build hash, adds API_BASE_URL as an env input, and declares the dist folder as an output so a cache hit restores the built files.
Which targets are safe to cache, and which to leave uncached
That failure comes from an incomplete hash. A second kind comes from caching a task whose result depends on things no hash can capture. Nx's caching guide states the rule: cacheable operations need to be side effect free. The same inputs must always produce the same output.
E2E tests against a live backend are the usual example of what to leave uncached. The result depends on the deployed API, its data and the network at the moment of the run. None of these are files in your workspace. A cached pass only shows that the backend behaved correctly the last time the hash matched. It says nothing about the backend's state now.
Nondeterministic tasks go wrong in one of two ways:
| Task behaviour | What happens with caching | Result |
|---|---|---|
| Embeds a timestamp, build ID or random value in output | Output differs each run, but the hash doesn't | Restored artifacts carry stale values |
| Makes network calls (fetching schemas, calling APIs) | Remote state changes without changing the hash | Cache hits replay outdated results |
| Flaky tests | A lucky pass gets stored | The flake stays hidden until the inputs change |
| Deploy, publish, migrate | A hit skips the side effect | Nothing ships, and the task still reports success |
Checklist before setting cache: true
- Does the task read only files and environment values that are declared as inputs?
- Is the output byte-for-byte reproducible from those inputs?
- Is it free of network calls and external state?
- Is its only effect writing to the declared outputs?
- Has it passed consistently, with no history of flaking?
If any answer is no, leave the target uncached, or remove the nondeterminism first.
Local cache vs remote cache, and the alternatives
The local cache lives on the machine that ran the task. On a developer laptop that is useful because the same machine runs the same tasks repeatedly. On an ephemeral CI runner, the cache is deleted when the job ends, so the next pipeline starts with nothing. A remote cache stores task results outside the runner, so a later pipeline run, or a developer pulling the same commit, can restore the output and terminal log instead of recomputing them. Nx Cloud is the managed option, and the documented way to connect a workspace is npx nx@latest connect (Nx docs).
| Option | Shared across runs | Shared with developers | Operational cost | Where artifacts live |
|---|---|---|---|---|
| No cache | No | No | None | Nowhere |
| Local cache only | No (on ephemeral runners) | No | None | Runner disk |
| CI directory caching of the Nx cache folder | Partly, keyed by your CI cache rules | No | Low; size limits and eviction apply | CI provider storage |
| Self-hosted remote cache | Yes | Yes, if reachable | You run, secure and back it up | Your infrastructure |
| Managed service (Nx Cloud) | Yes | Yes | Subscription; little to operate | Vendor infrastructure |
Trade-offs to check
Restoring a result means downloading it. For fast tasks or very large outputs, the transfer can take about as long as running the task, so compare timings on hit and miss before caching everything.
A shared cache is also a trust boundary. A poisoned entry is served to every consumer of that cache. Give write access only to trusted runs, such as main-branch builds, and make pull requests from forks read-only or uncached.
With a managed service, your build outputs are stored outside your own infrastructure. Check that this is acceptable before you connect the workspace.
#!/usr/bin/env bash
set -euo pipefail
# One-time setup, run from the workspace root: connect the workspace to Nx Cloud.
# This is a deliberate step. Run it only after you have agreed that build
# outputs may be stored outside your own infrastructure.
npx nx@latest connect
# On CI, pick the token by trust level. Trusted main-branch runs get a
# read-write token. Other runs, including PRs from forks, get a read-only token,
# so they can restore cached results but cannot write (or poison) cache entries.
# CI_BRANCH and both token variables are placeholders: they come from your CI system's settings.
if [ "${CI_BRANCH:-}" = "main" ]; then
export NX_CLOUD_ACCESS_TOKEN="${NX_CLOUD_RW_TOKEN:?missing read-write token}"
else
export NX_CLOUD_ACCESS_TOKEN="${NX_CLOUD_RO_TOKEN:?missing read-only token}"
fi
# Check whether caching pays off before you cache everything.
# Runs 1 and 2 must use a read-write token (a trusted run), because run 2 is
# what puts the results into the remote cache.
# 1) Cache miss: force every task to run.
time npx nx run-many -t build --skip-nx-cache
# 2) Normal run: the tasks run again, and their results are stored locally and remotely.
time npx nx run-many -t build
# 3) Remote hit: clear the local cache first, so results must be downloaded.
npx nx reset
time npx nx run-many -t build
# If step 3 takes about as long as step 1 for some targets (fast tasks or very
# large outputs), consider not caching those targets.
Connects a workspace to Nx Cloud, gives read-write cache access only to trusted main-branch runs (other runs get read-only), and times a cache miss against a remote cache hit to check whether downloading results is faster than running the tasks.
Putting it together in a pipeline, with impact on cost and engineering time
In the pipeline, the two mechanisms work in sequence. nx affected -t lint test build compares the pull request against its base branch and selects the projects whose files or dependencies changed. Nx then hashes each selected task's inputs and checks the remote cache. If the hash matches, Nx restores the outputs and logs instead of running the task. Affected narrows the set of projects, and the cache removes work that another runner or an earlier commit has already done.
Illustrative estimate (assumed numbers, not a benchmark). Assume 40 projects with 3 targets each and an average of 2 runner-minutes per task. A PR touches 3 projects that have 5 dependents, so 8 projects are affected. On a re-push, assume 15 of those 24 tasks have unchanged inputs and hit the cache.
Runner minutes = tasks executed × average minutes per task
| Scenario | Tasks executed | Runner minutes |
|---|---|---|
| No optimization | 40 × 3 = 120 | 240 |
| Affected only | 8 × 3 = 24 | 48 |
| Affected + warm cache | 24 − 15 = 9 | 18 |
Runner minutes drive cost directly. Developer wait time falls less predictably, because it depends on the critical path through the task graph and on parallelism, not only on the task count. Shorter feedback loops mean fewer context switches while a PR waits for CI.
There are limits. A cached 20-minute test suite is still a 20-minute suite on every miss, so caching can hide slow tasks without fixing them. A cold cache, such as after a lockfile bump that changes every hash, brings CI back to roughly its unoptimized speed. Budget for those runs.
Verifying that affected and caching behave correctly
A repeatable check
Run nx run-many -t build test twice on a clean checkout. The second run should report that every task was read from the cache. If any task re-executes, its hash includes something that changes between runs, such as a timestamp, a generated file, or an unpinned environment variable. Fix that before anything else, because an unstable hash turns every later measurement into noise.
Next, edit a file your inputs exclude, such as a README if you removed *.md from the hash. Rerun and confirm the cache still hits. A miss here means your inputs are broader than you intended. Then change a source file that the build actually compiles and confirm that the task misses. A hit here is the dangerous failure, because it means CI is shipping stale artifacts. It usually points to a missing input, such as a config file or an environment variable read at build time.
After a cache hit, delete the output directory, rerun, and check that the declared outputs are restored with the expected files. Nx restores only what you declared, so an undeclared folder will be missing and break downstream steps.
For affected detection, take a real PR branch. Compare nx show projects --affected against what you expect from the change. Use nx show project <name> and nx graph to trace any surprising dependency edge.
Repeat these checks whenever you add a plugin, a new tool config, or a build-time environment variable. Those are the changes that quietly make cache hits unreliable.
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 ci optimization, 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.