What the Nx task pipeline is: project graph vs task graph

Nx keeps two separate graphs, and confusing them is the most common reason a pipeline behaves unexpectedly.

The project graph describes your code. Each node is a project, identified from a project.json or from a package.json listed in your package manager's workspaces. Each edge is a dependency between projects. You rarely write these edges by hand. Nx infers them by analyzing source imports, installed packages and TypeScript configuration, and it caches the result so only changed files are reanalyzed.

The task graph describes work. Nx builds it each time you run a command. Each node is a target invoked on a project, such as myapp:build. The edges come from dependsOn. Nx combines the project graph with your dependsOn rules to decide which tasks must finish before others start.

This is why build order belongs to the task graph, not the project graph alone. The project graph says app imports lib-a. It does not say whether lib-a must be built before app is built, tested or linted. That decision is a contract you declare: ^build means "build my dependencies first," and a plain build entry under test means "build this same project before testing it."

Project graph (who imports whom)      Task graph for: nx test app

  app ──> lib-a ──> lib-b               app:test
                                           │  build   (same project)
                                           ▼
                                        app:build
                                           │  ^build  (dependencies)
                                           ▼
                                        lib-a:build
                                           │  ^build
                                           ▼
                                        lib-b:build

Arrows point to what must finish first. Execution runs bottom-up:
lib-b:build → lib-a:build → app:build → app:test

Without those dependsOn rules, the same project graph produces a single-node task graph, and app:test runs against whatever stale output happens to be on disk.

 PROJECT GRAPH  (inferred from imports, packages, TS config)

   ┌─────┐  imports  ┌───────┐  imports  ┌───────┐
   │ app │──────────>│ lib-a │──────────>│ lib-b │
   └─────┘           └───────┘           └───────┘
                         │
                         │  combined with dependsOn rules
                         ▼
 TASK GRAPH for `nx test app`      (A ──> B means A waits for B)

   ┌──────────┐
   │ app:test │                          runs 4th
   └──────────┘
        │ build   (same project)
        ▼
   ┌───────────┐
   │ app:build │                         runs 3rd
   └───────────┘
        │ ^build  (dependencies)
        ▼
   ┌─────────────┐
   │ lib-a:build │                       runs 2nd
   └─────────────┘
        │ ^build  (dependencies)
        ▼
   ┌─────────────┐
   │ lib-b:build │                       runs 1st
   └─────────────┘

The project graph records which projects import which. The task graph for nx test app is built from that graph plus dependsOn. A same-project build edge puts app:build before app:test, and ^build edges build each dependency first.

Where targets come from and how their configuration merges

Every target Nx runs comes from one of three places, and a single target can draw settings from all of them at once. Nx merges them in a fixed order, with later sources winning (Nx mental model):

Source Where it lives What it typically defines Precedence
Plugin-inferred task Tool config read by a plugin registered in nx.json, e.g. vite.config.ts for @nx/vite Command, cache settings, inputs, outputs, task dependencies 1 (lowest)
targetDefaults nx.json Workspace-wide rules such as build depending on ^build 2
Project configuration The project's project.json target or package.json script Per-project overrides and one-off targets 3 (highest)

This order explains most cases where a dependsOn appears to be ignored. Suppose you add ^build to build in targetDefaults, but one project's project.json declares its own dependsOn for build. The project-level value is applied last, so that project's task graph follows its local rule, not the workspace default.

Before editing any file, run nx show project <name>. It prints the fully merged configuration for every target, so you see what Nx will actually execute. Add --web to open a browser view that shows which source contributed each setting. That tells you which file to change.

Inferred tasks remove duplicated configuration because the tool config stays the single source of truth. The cost is that a target's resolved settings can change after a plugin upgrade or an edit to vite.config.ts, while project.json shows no diff. Checking the nx show project output during code review, or after an upgrade, catches these silent changes.

# 1. Inspect the fully merged config before editing any file.
#    This is what Nx will actually run, after inferred settings,
#    targetDefaults and project.json/package.json are combined.
nx show project my-app

# 2. Open the browser view to see which source contributed each setting
#    (plugin-inferred, nx.json targetDefaults, or the project itself).
nx show project my-app --web

# 3. Check the resolved dependsOn for one target.
#    If you expected ["^build"] from targetDefaults but see something else,
#    the project's own config is overriding it.
nx show project my-app --json | jq '.targets.build.dependsOn'

# 4. Catch silent changes to inferred tasks across a plugin upgrade
#    or a vite.config.ts edit, where project.json shows no diff.
nx show project my-app --json | jq '.targets' > /tmp/targets-before.json

# ... upgrade the plugin or edit vite.config.ts here ...

nx show project my-app --json | jq '.targets' > /tmp/targets-after.json
diff /tmp/targets-before.json /tmp/targets-after.json

Using nx show project to inspect merged target configuration, trace an overridden dependsOn, and diff resolved targets before and after a plugin upgrade or tool config change.

How dependsOn works: build vs ^build

An entry in dependsOn is a reference to another task. A bare name such as "build" means "run this target on the same project first." For example, "dependsOn": ["codegen"] on a build target makes myapp:codegen run before myapp:build. The caret form "^build" means "run build on every project this one depends on first." Nx reads those dependencies from the project graph, so ^build produces dependency-first build order across libraries without anyone listing them.

Both strings are shorthand for an object form. "^build" is equivalent to { "target": "build", "dependencies": true }. Use the projects field instead when you need to name specific projects.

Edges define order, not sequence

Nx runs tasks concurrently unless an edge connects them. Two tasks with no edge between them can start at the same moment. Common pairs that need explicit edges include:

  • code generation before compilation
  • database migrations before integration tests
  • a schema export before a client build

Without the edge, these steps race.

The two possible mistakes cost you in different ways:

  • A missing edge often passes locally. Earlier runs or cache hits leave the generated files on disk. In CI, with more parallel workers and a cold cache, the build fails intermittently. These failures get misread as flaky infrastructure.
  • An extra edge never breaks the build. It forces tasks to run one after another, so wall-clock time grows while CI agents sit idle.

Nx cannot catch the first mistake for you. If a dependency exists only at runtime and not in imports, package.json or TypeScript configuration, it is absent from the project graph. Nx has no information that would let it add that edge or warn you that it is missing.

{
  "name": "myapp",
  "targets": {
    "codegen": {
      "executor": "nx:run-commands",
      "options": { "command": "node tools/generate-api-types.js" }
    },
    "build": {
      "executor": "nx:run-commands",
      "dependsOn": ["^build", "codegen"],
      "options": { "command": "tsc -p apps/myapp/tsconfig.app.json" }
    },
    "migrate": {
      "executor": "nx:run-commands",
      "options": { "command": "node tools/run-migrations.js" }
    },
    "integration-test": {
      "executor": "nx:run-commands",
      "dependsOn": [
        "build",
        "migrate",
        { "target": "build", "projects": ["shared-schema"] }
      ],
      "options": { "command": "node tools/run-integration-tests.js" }
    }
  }
}

A project.json where build waits for dependency builds (^build) and local codegen, and integration-test waits for build, migrate and an explicitly named project's build; with no edge, these tasks could run in parallel and race.

Implementing a pipeline with targetDefaults and project overrides

The fix is to declare those edges yourself, and the most maintainable place to start is targetDefaults in nx.json. A typical pipeline sets build to depend on ["codegen", "^build"], so each project generates its code and waits for its dependencies' builds first. test depends on ["build"] in the same project. Because these defaults apply to every project with a matching target name, you write the ordering once instead of copying it into each project.

Ordering alone does not keep the cache correct. When build is a cache hit, Nx restores only the paths listed in its outputs, such as {projectRoot}/dist. If codegen writes to a directory that is not declared, a cache hit replays the terminal log but leaves the generated files absent or stale, and the downstream build breaks or compiles old code. Inputs have the opposite failure mode. If test takes default and ^default as inputs, it is invalidated by edits to READMEs and lint configs. Narrowing to a production named input for upstream projects keeps unrelated edits from forcing reruns.

Use a project's project.json only where that project really differs. One example is a service whose build must also wait on a protobuf generator in another project, expressed as a dependsOn entry naming that project and its generate target. For relationships Nx cannot see, such as a Go binary, a SQL schema or a service called only at runtime, add the project names to implicitDependencies. Nx never verifies these declarations, so they go stale silently when the architecture changes. Review them alongside the code they describe.

# nx.json: pipeline order, cache inputs and cache outputs, set once for every project.
cat > nx.json <<'EOF'
{
  "namedInputs": {
    "default": ["{projectRoot}/**/*"],
    "production": ["default", "!{projectRoot}/**/*.spec.ts", "!{projectRoot}/**/*.md"]
  },
  "targetDefaults": {
    "codegen": { "cache": true, "inputs": ["{projectRoot}/schema/**/*"],
                 "outputs": ["{projectRoot}/src/generated"] },
    "build": { "cache": true, "dependsOn": ["codegen", "^build"],
               "inputs": ["production", "^production"], "outputs": ["{projectRoot}/dist"] },
    "test": { "cache": true, "dependsOn": ["build"], "inputs": ["default", "^production"] }
  }
}
EOF
# codegen declares outputs, so a cache hit restores src/generated and does not only replay the log.
# build reads only production files, so edits to specs or READMEs do not invalidate it.
# test reads only the production files of upstream projects, so their test edits do not rerun it.

# apps/orders-service/project.json: override only for the project that differs.
cat > apps/orders-service/project.json <<'EOF'
{
  "name": "orders-service",
  "implicitDependencies": ["billing-sql-schema", "pricing-go"],
  "targets": {
    "build": {
      "dependsOn": ["codegen", "^build",
        { "projects": ["proto-contracts"], "target": "generate" }]
    }
  }
}
EOF
# The project-level dependsOn replaces the default array, so it repeats codegen and ^build.
# billing-sql-schema and pricing-go must exist as Nx projects. Nx cannot verify these edges.
# Review them whenever the service's runtime or tooling dependencies change.

# Check that the resulting graph matches what you intended.
npx nx graph

The example has an nx.json targetDefaults pipeline (codegen, then build, then test) with declared inputs and outputs. It also has a project.json override that adds a cross-project protobuf generate step and implicit dependencies on non-JS projects that Nx cannot infer.

Affected runs and debugging the task graph

nx affected works in two stages. First it compares files between a base and head commit (--base and --head), maps changed files to projects, and adds every project that depends on them in the project graph. That gives the set of affected projects. Then, for the target you asked for, Nx builds the task graph and expands it through dependsOn. A ^build rule pulls in the build task of each dependency, even when that dependency is not affected.

As a result, an affected CI run can still execute upstream tasks in unaffected projects. If those tasks have stable inputs and a cache is available, Nx restores their outputs instead of running them. Without a warm cache, a one-line change in an app can still trigger builds of every library beneath it. This is correct behavior, but it can look like a bug.

When the task graph does not match what you expect, work through it in this order:

  1. View the task graph for the exact command. Use nx graph or add --graph to the run command, such as nx affected -t build --graph.
  2. Check the merged configuration with nx show project <name>. It shows the final target definition and where each setting came from.
  3. Confirm each edge matches intent. For every arrow, name the reason it exists. Remove edges that have no reason.
  4. Only then tune CI parallelism. Raising parallelism on a graph with wrong edges makes the run fail faster without fixing it.

Step 2 is the one teams skip most often. Configuration merges from three sources: plugin inference, targetDefaults, and project files. The project's own file wins over the other two. Engineers edit targetDefaults in nx.json and see no change because a project.json override takes precedence. Running nx show project first points you to the file that actually controls the value (source).

Who should use explicit pipelines, when to use them, and when to avoid them

Explicit pipelines pay off most for backend and platform engineers running multi-project monorepos: services that consume shared libraries, packages whose sources come from codegen (OpenAPI clients, protobuf stubs, ORM schemas), or polyglot setups where a Go or Python build has to wait on a generated artifact that Nx can't infer from imports. In these repos the order is real but hard to see. Writing it down as dependsOn lets Nx run independent branches of the task graph in parallel in CI and skip work whose inputs haven't changed, whether the cache is local or remote.

Skip the pipeline, or keep it minimal, in a repo with one or two projects. There is little ordering to express, and a plain script is easier to read. The same goes for workflows that are already strictly sequential, where a pipeline adds configuration but no parallelism. Be most careful with teams that can't keep inputs and outputs accurate. A pipeline that relies on caching with wrong inputs will restore stale results, and that is worse than rebuilding every time.

Approach Example Strength Cost
Broad default test depends on ^build in targetDefaults Simple and correct for every project On a cold cache, testing one leaf builds its whole upstream chain
Narrow per-project Only api:test depends on codegen:generate Runs only what is required Easy to get wrong, and it drifts as dependencies change

Start broad. Narrow a project's dependsOn only when the task graph shows that the extra builds are a measurable bottleneck.

Alternatives and impact on cost and engineering time

Each alternative gives up at least one of ordering, parallelism or caching.

Approach Ordering guarantee Parallelism Caching Maintenance effort
Chained package.json scripts Fixed sequence you write by hand None unless you add it None Low at first, grows with every new project
Workspace recursive runs Usually topological by package dependencies Often available None built in Low, but no per-task dependencies
Makefiles File-timestamp rules you declare Available with job flags Timestamp-based, local only High; every edge is hand-written
Hand-written CI job ordering Whatever the pipeline file says Per job Per job, configured separately High; drifts from the code
Nx task pipeline Derived from the project graph plus dependsOn Scheduled across the task graph Local and shared, keyed on inputs Moderate; inputs and outputs need care

Illustrative estimate (assumed numbers, not a benchmark)

Assume 40 projects, a 2-minute build each, and a 75% cache hit rate on a typical pull request.

Compute minutes ≈ projects × task duration × (1 − hit rate)

  • Cold cache: 40 × 2 × 1 = 80 minutes
  • Warm cache: 40 × 2 × 0.25 = 20 minutes

^build changes neither figure directly. It makes the cached results trustworthy, because a dependent's hash includes its upstream outputs. Without that ordering, teams often disable caching after hitting a stale result.

Engineering time is saved in three places. Race failures go away when a consumer starts before its library is built. Ordering rules live in one place instead of in every CI job. Affected runs skip unchanged projects.

Engineering time is spent elsewhere. Someone must keep inputs, outputs and implicit dependencies accurate, because a missing input produces a wrong cache hit. Plugin upgrades can also change inferred targets, so review nx show project output after each upgrade.

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 task pipeline, 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.