What an unexpected edge is and why it costs you in CI

An edge in the Nx project graph is a directed dependency: project A points to project B when Nx has concluded that A depends on B. Edges decide what affected selects. A change to B marks B and every project that reaches it through incoming edges. Edges also feed task hashing, because inputs that reference dependencies (the common setup for build targets) change when a dependency changes. And with dependsOn on ^build, edges set task order.

An edge is unexpected when no one meant to create it. Typical causes are a stray deep import, a leftover implicitDependencies entry, a package.json reference, or a dependency inferred from config. The edge exists in the graph either way, and Nx acts on it exactly as it acts on an intended one. The results are wider affected sets, cache misses on projects that changed nothing, serialized builds that could have run in parallel, and sometimes a cycle that blocks task ordering or fails the module-boundary lint rule.

This guide is for backend and platform engineers who own CI, shared libraries or workspace config. Start debugging when an affected run touches more projects than the change could reasonably reach, or when cache misses appear with no obvious cause.

Illustrative estimate (assumed numbers, not a benchmark)

Assume a shared library core has 60 dependents. Someone adds an import from core into a feature library billing. That creates the edge core → billing. From then on, every change to billing also affects core and all 60 of its dependents.

Extra CI minutes per week = (dependents of core + 1) × minutes per project × weekly changes to billing

Plugging in assumed values of 4 minutes per project and 10 changes per week:

(60 + 1) × 4 × 10 = 2,440 minutes per week

All of that comes from a single import line.

Who should use this approach

Edge tracing pays off for anyone who has to explain or control what Nx decides to run:

  • Platform and CI owners. You own pipeline duration and cache hit rates. When nx affected selects too much, you are the one asked why.
  • Maintainers of shared libraries. Libraries low in the graph, such as utilities, domain types and UI kits, have the most dependents. One wrong edge into or out of them has the largest blast radius.
  • Engineers restructuring a workspace. Splitting, merging or moving libraries changes edges. Tracing confirms that the graph after a refactor matches what you intended.
  • Teams adopting module boundary rules. Before you write tag constraints, you need to know which edges exist today and why.

It is less useful in a small workspace where running every task is already fast. If affected and full runs take about the same time, unexpected edges cost little. The cleanup can wait until the workspace grows.

How the debugging workflow works

The method treats every surprising CI result as a question about one or more edges, and answers it in a fixed order:

  1. Notice the symptom. An affected set is too large, a cache miss has no obvious cause, tasks run serially, or a cycle is reported.
  2. Identify the edge. Use nx graph --focus and the path view to find which chain of projects connects the changed project to the one that should not have been selected.
  3. Classify the edge. Export the graph JSON and read the edge's type. static and dynamic point to source code. implicit points to configuration. An edge with no matching import points to package.json or plugin inference.
  4. Trace it to its origin. Search the source for the target's alias and package name, inspect implicitDependencies, and compare nx show project --json against the declared configuration.
  5. Fix and guard. Remove the import or config entry, break the cycle, and add a lint constraint or graph-diff check so the edge does not come back.

The sections below follow these steps in order.

How Nx creates edges: the five sources and how to tell them apart

A single edge is enough to cause that cost, so the first step is to identify which mechanism created it. Nx builds the project graph from five sources. In the exported graph JSON, each edge records a type of static, dynamic, or implicit. That field is the quickest way to narrow the search.

  • Static imports. Nx scans source files and resolves import specifiers against your path aliases. An import that only pulls in types still counts, because Nx does not check whether the import survives compilation.
  • Dynamic imports. import() calls with a literal string become dynamic edges. If the specifier is built at runtime, Nx cannot resolve it and creates no edge.
  • Workspace package.json dependencies. A dependency that names another workspace package creates an edge, even if no code imports it.
  • implicitDependencies. These are declared by hand in project configuration. A !name entry removes an edge that was inferred.
  • Plugin inference. Plugins read tool config files and contribute both projects and dependencies.

dependsOn is not on this list because it creates no project edges. It tells Nx how to turn the project graph into the task graph, for example by making build run ^build on dependencies first. An unwanted edge in the project graph therefore also appears in the task graph.

Source Declared or detected in How to confirm Typical failure
Static import Source files type: static in graph JSON Over-reports (type-only, test files, barrels)
Dynamic import Source files type: dynamic Under-reports computed specifiers
package.json dependency Project package.json static edge with no matching import Over-reports stale entries
implicitDependencies Project configuration type: implicit Over-reports after refactors
Plugin inference Tool config files nx show project, then disable the plugin Either direction

[source files] --imports--> [project graph] [project config] --implicitDependencies--> [project graph] [tool configs] --plugin inference--> [project graph] [project graph] + dependsOn --> [task graph]

+----------------+ +----------------+ +----------------+ +----------------+
| Source files   | | package.json   | | Project config | | Plugins read   |
| import 'x'     | | deps naming    | | implicitDeps   | | tool configs,  |
| import('x')    | | workspace pkgs | | '!name' drops  | | infer projects |
| type-only too  | |                | |                | | + dependencies |
+-------+--------+ +-------+--------+ +-------+--------+ +-------+--------+
        |                  |                  |                  |
 static/dynamic         static            implicit        plugin-defined
        |                  |                  |                  |
        +------------------+------------------+------------------+
                                     |
                                     v
                 +---------------------------------------+
                 | PROJECT GRAPH                         |
                 | projects + edges, each with a type:   |
                 | static | dynamic | implicit            |
                 +---------------------------------------+
                                     |
                                     |  <-- dependsOn in target config
                                     |      (e.g. build: ^build)
                                     v
                 +---------------------------------------+
                 | TASK GRAPH                            |
                 | tasks (project:target), ordered by    |
                 | dependsOn over project graph edges    |
                 +---------------------------------------+

Source imports, package.json dependencies, implicitDependencies and plugin inference all add edges to the project graph, and dependsOn adds no edges of its own, only setting how the task graph is derived from those edges.

Inspecting what Nx actually computed: nx graph and nx show project

Opening the full graph of a large workspace shows you every project and every edge at once, which makes it hard to find any single problem. Narrow it first. nx graph --focus=<project> limits the view to one project and its direct and transitive neighbors. In the graph UI, you can also pick a start project and an end project and display the path between them. That path view usually answers the real question: not "does api depend on ui-kit?" but "through which chain of projects does it?" Often the culprit is one intermediate library that pulls in far more than it should.

The picture only tells you where to look. An edge tells you a dependency exists, but in most cases it does not show which import, config entry or plugin created it. Note the specific edge you need to explain, then move to the project's configuration.

nx show project <name> --json prints the project as Nx resolved it, after plugins have run. This output includes inferred targets, the inputs and dependsOn settings attached to them, tags, and any implicitDependencies. Compare it against project.json or the nx field in package.json. If the resolved output contains a target or dependency that neither file declares, a plugin or a workspace-level default in nx.json added it. That narrows your search to inference rather than source imports.

CLI flags and the graph UI's controls have changed between Nx releases. Before relying on the options above, check them with nx graph --help and nx show project --help on your installed version.

npx nx graph --help
npx nx show project --help

npx nx graph --focus=api

npx nx show project api --json > /tmp/api-resolved.json

jq '{tags, implicitDependencies}' /tmp/api-resolved.json
jq '.targets | map_values({dependsOn, inputs})' /tmp/api-resolved.json

jq -r '.targets | keys[]' /tmp/api-resolved.json | sort > /tmp/resolved-targets.txt
jq -r '(.targets // {}) | keys[]' apps/api/project.json | sort > /tmp/declared-targets.txt
comm -23 /tmp/resolved-targets.txt /tmp/declared-targets.txt

Focus the graph on one project, dump its resolved configuration with nx show project --json, and list targets that are inferred rather than declared in project.json.

Querying the exported graph JSON to find every edge in or out

The visual graph works for a single question about a single project. It does not scale when you need every edge touching a project, or when you want the same check to run again next week. For that, export the graph with nx graph --file=graph.json and query the file.

In the releases I have worked with, the export has a top-level graph object containing nodes and dependencies. dependencies maps each project name to an array of edge objects. Each edge has source, target, and type fields.

  • Outgoing edges for a project are its own entry in dependencies.
  • Incoming edges are not stored per project. You have to scan every entry and keep the edges whose target matches. Any script that only reads the project's own key will miss its dependents, so this asymmetry is worth handling explicitly.

Once you have both lists, group them by type. A static edge comes from an import that Nx detected in source. An implicit edge comes from configuration, such as implicitDependencies. You may also see dynamic edges for lazy imports. Splitting by type tells you whether to search code or config next.

The structure of this file is not a stable public contract. Key names and nesting have changed between Nx releases. Before trusting a script:

  1. Run it against the installed version.
  2. Make it fail loudly when an expected key is missing, rather than returning an empty list. An empty result looks exactly like "no dependencies," which is the wrong conclusion to draw silently.

If edges have no type field, report that the type is unknown instead of assuming one.

#!/usr/bin/env python3
import json, sys
from collections import defaultdict

path, project = sys.argv[1], sys.argv[2]
with open(path) as f:
    data = json.load(f)

graph = data.get("graph")
if not isinstance(graph, dict) or "dependencies" not in graph or "nodes" not in graph:
    sys.exit(f"unexpected export layout, top-level keys: {list(data)}")
deps = graph["dependencies"]
if project not in graph["nodes"]:
    sys.exit(f"project '{project}' not found in graph.nodes")

def edge_type(edge):
    # Do not guess a type when the field is absent.
    return edge.get("type", "unknown")

outgoing = deps.get(project, [])
incoming = [e for edges in deps.values() for e in edges if e.get("target") == project]

for label, edges, other in (("out", outgoing, "target"), ("in", incoming, "source")):
    by_type = defaultdict(list)
    for e in edges:
        if other not in e:
            sys.exit(f"edge missing '{other}' field: {e}")
        by_type[edge_type(e)].append(e[other])
    print(f"{label}: {len(edges)} edge(s)")
    for t, names in sorted(by_type.items()):
        print(f"  {t}: {', '.join(sorted(names))}")

A defensive Python script that reads an exported Nx graph, finds a project's outgoing edges and its incoming edges (by scanning every project's dependency list), groups them by edge type, and exits with an error when the expected keys are missing.

Tracing an edge back to the file or config that created it

Once you know an edge's type, work backward from it. Start with the target, not the source. Search the source project for every string that can resolve to the target: its tsconfig path alias, its package name, and any relative path that climbs into its directory. Nx's static analysis does not distinguish production code from tests or type-only imports. An import type in a spec file creates the same edge as a runtime import in main.ts. A re-export inside a barrel index.ts also counts, even if no consumer ever touches that symbol.

If the search finds nothing, the edge is probably not from source code. Check implicitDependencies in the project's configuration, then look at what any inference plugin actually produced. nx show project <name> displays the resolved targets and inputs, so you inspect the plugin's output instead of guessing at its logic.

Culprit Why it creates the edge Where to look
Broad tsconfig path alias A pattern like @org/* resolves imports to the wrong project Root tsconfig.base.json paths
Root-level barrel file One import pulls in every re-exported project index.ts files that re-export libs
Shared test utilities Production code imports a helper meant for specs Non-spec files importing testing libs
Stale implicitDependencies The declared link outlived the reason for it project.json or package.json nx field
Wildcard workspace globs Unintended folders become projects and get matched Root package.json workspaces

When none of these explain the edge, rule out stale state. Run nx reset to stop the daemon and clear the cache, then regenerate the graph. If the edge disappears, the cached graph was out of date. Do not make the reset a reflex. Each reset forces a cold rebuild of the graph and the cache. It can also mask a real configuration problem that will come back the next time the daemon caches the same bad input.

#!/usr/bin/env bash
SRC=web-app
SRC_DIR=apps/web-app
TARGET=testing-utils
ALIAS='@acme/testing-utils'

npx nx graph --file=graph.json
jq --arg s "$SRC" --arg t "$TARGET" \
  '.graph.dependencies[$s][] | select(.target == $t)' graph.json

grep -rnE "from ['\"]${ALIAS}|export \* from ['\"]${ALIAS}|import type .*${ALIAS}" \
  "$SRC_DIR" --include='*.ts' --include='*.tsx'

jq '.compilerOptions.paths' tsconfig.base.json

npx nx show project "$SRC" --json | jq '.implicitDependencies'
jq '.workspaces' package.json   # wildcard globs can create unintended projects

npx nx reset
npx nx graph --file=graph-after-reset.json
jq --arg s "$SRC" --arg t "$TARGET" \
  '[.graph.dependencies[$s][] | select(.target == $t)] | length' graph-after-reset.json

A repeatable shell procedure that works backward from one edge: it confirms the edge in the exported graph, searches the source for the target's alias, checks path aliases, implicitDependencies and workspace globs, and uses nx reset only as a final check for stale state.

Breaking circular dependencies without adding exceptions

A cycle is never one bad edge. It is a loop of edges, and you only need to remove one. Use the edge-tracing approach from the previous section to get the full path, then check each edge for how much code actually crosses it. The weakest edge is usually small: one type or helper imported through a barrel index.ts. The barrel makes the whole library a dependency, even though only one symbol is needed.

Before and after

Before: orders imports InvoiceStatus from billing's barrel. billing imports the OrderId type from orders. Neither library needs the other's logic. Both need two small types.

After: move InvoiceStatus and OrderId into a new shared-domain-types library that has no dependencies of its own. Both orders and billing now depend on it, and the edges between them disappear. Because the new library sits lower in the graph, the cycle cannot return unless someone adds an upward import, and the module boundaries lint rule will flag that.

When not to split

Each new library adds a project, its configuration and an owner. If the moved code is trivial, or the two projects always change together, splitting only adds nodes to the graph without making it easier to understand.

Option Use when Cost
Extract a library Shared code is stable and clearly lower-level New project, config, ownership
Invert the dependency One side can accept an interface or callback Indirection at call sites
Merge the projects They always change and release together Larger affected sets in CI
Accept with exception Temporary, with a removal date Cycle stays; exceptions accumulate

Preventing regressions and the trade-offs of each guard

The cheapest guard is the module boundary lint rule (@nx/enforce-module-boundaries) combined with project tags. Tag each project by type and scope, such as type:feature, type:util, scope:billing. Then declare which tags may depend on which. A forbidden edge then fails lint at the import site, before it reaches the graph. Static rules miss config-level changes, though. For pull requests that touch shared libraries, tsconfig path aliases or workspace config, export the graph JSON on the base and head commits and review the edge diff.

Trade-offs

  • Strict constraints stop real mistakes. If the tags don't match how teams actually work, they also block legitimate changes. Engineers then add lint-disable comments, and those hide the very edges you wanted to see. When disables accumulate around one tag pair, the tags are wrong, not the code.
  • Static analysis over-reports type-only and test-only imports. It under-reports runtime lookups, string-built require calls and non-JS dependencies such as SQL migrations or shared protobuf files. implicitDependencies fill that gap. Nothing tells you when one of them goes stale, so review them on a schedule.
  • Plugin inference removes boilerplate. It also means reading project.json no longer tells you the whole graph.

The time savings come from smaller affected sets, fewer cache misses and faster diagnosis when an edge appears. The cost goes into designing the tag taxonomy, maintaining diff scripts, and the extra libraries you split out to satisfy the rules.

Alternatives to tracing and pruning edges

Edge tracing is not the only response to an oversized affected set. Each alternative trades diagnosis effort for something else:

  • Run everything on every change. Skip affected and run all targets, relying on the computation cache to skip unchanged work. This is simple and immune to under-reported edges. Wrong edges still cause cache misses, though, and total CI time grows with the size of the workspace.
  • Rely on lint rules alone. @nx/enforce-module-boundaries with tags prevents new forbidden imports, but it does not explain edges that already exist. It also does not see edges from implicitDependencies, package.json or plugins.
  • Tune task inputs instead of edges. Narrowing inputs, for example excluding spec files from production build inputs through named inputs in nx.json, reduces cache misses without changing the graph. The edge remains, so affected still selects the dependent projects.
  • Read the code by hand. Searching imports manually works for one edge in a small workspace. It misses config-derived and plugin-inferred edges, and it cannot be repeated in CI.
  • Use a build system with explicitly declared dependencies. Systems where every dependency must be written in a build file avoid inference surprises entirely. In exchange, you maintain those declarations by hand, and adopting such a system means migrating away from Nx.

For most Nx workspaces, the practical combination is to trace and fix existing edges once, then keep lint rules and a graph-diff check in place so the workspace stays clean.

How this saves engineering time

The savings show up in three places:

  • CI compute and wait time. Removing an unintended edge shrinks every affected run that would have crossed it. The illustrative estimate in the first section shows how one edge into a widely used library multiplies across dependents and weekly changes.
  • Diagnosis time. Without a method, an engineer investigating "why did this run rebuild half the workspace?" tends to read code and configuration at random. The fixed sequence of focus, classify by type, then trace to the origin turns that into a bounded procedure. Because the scripts above are reusable, the second investigation is faster than the first.
  • Developer feedback loops. Smaller affected sets and fewer cache misses mean pull request checks finish sooner. That reduces context switching while engineers wait.

Illustrative estimate (assumed numbers, not a measured result)

Assume a team investigates 3 unexpected-edge incidents per month. Assume each takes 3 hours when done ad hoc, and 1 hour when done with the scripted procedure and an exported graph.

Hours saved per month = incidents × (ad hoc hours − scripted hours) = 3 × (3 − 1) = 6 hours

Add the up-front cost before counting that as a gain. Assume 8 hours to write and validate the scripts against your installed Nx version, plus occasional maintenance when the export format changes. On these assumed inputs the effort pays for itself in under two months, before counting any CI minutes saved. Substitute your own incident rate and timings. If unexpected edges are rare in your workspace, the lint rule alone may be enough.

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 project graph debugging, 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.