Why one CI duration number misleads you

A CI job that reports "38 minutes" tells you almost nothing about where those minutes went. That single wall-clock figure blends several independent factors. Some tasks were restored from cache and took seconds. The --parallel setting capped how many tasks ran at once. The agent's CPU count decided whether those parallel tasks actually got a core or competed for one. Dependency ordering forced some tasks to wait for others no matter how much capacity sat idle.

Because of this mix, the slowest target is not necessarily the one costing you time. Take an illustrative estimate. A 9-minute integration test suite runs alongside a 12-minute chain of build tasks it does not depend on. Cutting the test suite to 2 minutes changes total time by zero. Shaving one minute off any link in the build chain saves a full minute.

Nx task timing analysis answers this directly. It is the practice of collecting per-task durations from real runs and combining them with the task graph's dependency edges. From those two inputs you find which tasks sit on the critical path, the longest dependent chain that sets the minimum possible run time.

This tutorial is for backend and platform engineers who own CI time and have to justify changes to it. Run the analysis in three situations:

  • when CI duration has grown and nobody can say why
  • before buying more agents, since added parallelism does nothing for a serial chain
  • before restructuring libraries, so the split targets the tasks that actually gate the run

Who should use task timing analysis

The analysis pays off for people who can act on its results:

  • Platform and CI owners who control agent sizing, --parallel settings, and pipeline layout. They need to know whether a slow pipeline is short on capacity or blocked by a dependency chain.
  • Backend engineers who own shared libraries that many services depend on. Their library's build often sits at the root of the longest chain, and they decide how it is split and what its inputs are.
  • Tech leads deciding where to spend optimization effort. A ranked list of critical-path tasks turns "CI is slow" into a specific target with an expected payoff.

It fits Nx workspaces with many projects and real dependency depth: services that build on shared libraries, which in turn build on lower-level libraries. You need permission to change nx.json and project.json, and access to run CI jobs on the same agent type your pipeline uses. Without both, you can identify the bottleneck but not test a fix.

When to use it and when to avoid it

When it should be used

  • CI time has crept up gradually and no single commit explains it. Recomputing the critical path shows which chain grew.
  • A capacity decision is pending. If the critical path is close to the observed wall-clock time, more agents or a higher --parallel will not help. If wall-clock time is far above the critical path, tasks are queueing for slots and capacity is the limit.
  • Before a library split or a dependsOn change. The analysis tells you which edges and which projects actually gate the run, so the restructuring targets them.
  • After a large change to inputs or graph structure, to confirm the bottleneck moved where you expected and did not reappear elsewhere.

When it should be avoided

  • Most CI time is spent outside Nx tasks. If checkout, dependency installation, container image pulls, or artifact upload dominate the job, the task graph does not model those steps. Measure the pipeline stages first, and only analyze tasks if they make up a meaningful share.
  • The workspace is small or flat. With a handful of projects and few dependency edges, the critical path is roughly the slowest single task. Sorting by duration gives you the same answer for less work.
  • Timings are too noisy to rank. On heavily shared runners where the same task varies widely between runs, medians may still not separate candidates. Fix the measurement environment, or use dedicated agents for profiling runs, before trusting the ranking.
  • The real problem is failures, not duration. Flaky tests and retries add time that a duration analysis attributes to the wrong cause. Stabilize them first.
  • The cache already absorbs almost everything. If nearly all PR runs hit the cache for path tasks, the uncached critical path rarely occurs in practice. The CI minutes spent profiling may cost more than the improvement is worth. Look at cache misses instead.

How it works

The method has four steps, each covered in its own section below:

  1. Export the task graph. Nx can write the graph for a given run-many or affected invocation to a JSON file without executing tasks. This gives you every project:target task and the dependency edges between them, derived from your dependsOn rules.
  2. Collect uncached per-task durations. Run the same targets with --skip-nx-cache on real CI agents, several times, and record start and end times per task, either through NX_PROFILE or a timing wrapper. Take the median duration for each task.
  3. Join durations to the graph and compute the critical path. For each task in topological order, finish time is its own duration plus the latest finish among its dependencies. Walking back from the latest-finishing task gives the longest dependent chain. That chain is a lower bound on wall-clock time under unlimited parallelism.
  4. Rank, fix, and re-measure. Weight path tasks by how often they run and how often they miss the cache, diagnose the top candidates, apply a fix, then rerun uncached and recompute. A fix can move the bottleneck rather than remove it.

The key idea is that duration alone is not enough, and neither is the graph alone. A slow task with slack costs nothing in wall-clock time. A fast task on a long chain costs its full duration. Only the combination tells you which work gates the run.

Project graph vs. task graph, and how to export it

Nx tracks dependencies at two levels, and timing analysis needs the lower one. The project graph, which nx graph visualizes, records which projects import or declare dependencies on which others. It says that orders-api depends on shared-db, but nothing about when orders-api:test may start.

The task graph is derived from the project graph and your dependsOn rules, in nx.json target defaults or per-project configuration. A rule like "dependsOn": ["^build"] on build means "before building this project, build every project it depends on." The caret turns a project edge into a task edge: shared-db:build must finish before orders-api:build. A rule without the caret, such as test depending on build, adds an edge inside the same project. Tasks with no path between them can run in parallel. Tasks connected by edges always run in sequence, however many agents you add. Those edges are what the critical path calculation walks.

To export the task graph for a real invocation, pass --graph with a filename to the run command. Use nx run-many -t build test --graph=tasks.json for everything, or the same flag on nx affected to see only what a given change would run. Nx writes the file instead of executing the tasks.

Each task is identified by project:target, with :configuration appended when one applies, for example orders-api:build:production. Dependencies are listed as edges between these IDs. Open your own file and confirm where nodes and edges live before you write any parsing code. The JSON layout has changed between Nx versions, so a script written against someone else's output may silently read the wrong fields.

npx nx run-many -t build test --graph=tasks.json

npx nx affected -t build test --base=main --graph=affected-tasks.json

jq 'to_entries | map({key, type: (.value | type)})' tasks.json

jq -r '[paths | select(length <= 3) | map(tostring) | join(".")] | unique | .[]' tasks.json | head -n 40

jq -r 'paths(type == "string" and test("^orders-api:build")) | map(tostring) | join(".")' tasks.json
jq -r '[paths | map(tostring) | join(".")] | .[] | select(test("orders-api:build"))' tasks.json

Exports the task graph for run-many and affected, then uses jq to list the file's actual keys and find where task IDs like orders-api:build appear, so the parser is written against your Nx version's real layout.

Collecting honest per-task timings from uncached runs

Cached durations are useless for this exercise. A cache hit reports how long it took to restore outputs, not how long the work takes. Run the full backend target set with --skip-nx-cache so every task executes. Do it on the same agent type, image, and parallelism setting your pipeline uses. A laptop has different core counts, disk speeds, and warm dependency stores, so its numbers will rank targets differently than CI does.

Option 1: NX_PROFILE

Setting the NX_PROFILE environment variable to a file path makes Nx write a Chrome trace-format profile of the run. Each task appears as an event with start and end timestamps, and you can load the file into any trace viewer or parse it directly. Support and output details vary by release, so confirm it works with your installed Nx before you build tooling on it.

Option 2: A timing wrapper

This fallback works with any Nx version. Point each target's command at a small wrapper script that does three things:

  1. Records a wall-clock timestamp on start.
  2. Runs the real command.
  3. Appends the project, target, start time, end time, and exit code to a log file as one line.

Keep the output format append-only and one line per task so that parallel tasks don't corrupt each other's writes.

Reducing noise

Expect a single run to be noisy. Several factors inflate durations unevenly:

  • Neighboring workloads on shared hosts
  • Cold disks
  • Container image pulls
  • Dependency installs that land inside the first task to need them

Collect at least several uncached runs, ideally at different times of day. For each task, use the median duration. A task that is slow only once is a noise problem. A task that is slow every time is a candidate for the next step.

#!/usr/bin/env bash
set -u

project="$1"; target="$2"; shift 2
[ "${1:-}" = "--" ] && shift

log="${TASK_TIMING_LOG:-/tmp/nx-task-timings.tsv}"
run_id="${RUN_ID:-local}"

start_ms=$(date +%s%3N)

"$@"
status=$?

end_ms=$(date +%s%3N)

printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
  "$run_id" "$project" "$target" "$start_ms" "$end_ms" "$status" >> "$log"

exit "$status"

A Bash wrapper logs the run ID, project, target, start and end times, and exit code as one appended line per task, and a short awk pipeline computes the median duration per task across several uncached runs.

Computing the critical path from durations and graph edges

A task that is consistently slow across uncached runs is a real bottleneck candidate. The next step is to find out whether that candidate actually holds up the pipeline.

The script takes two inputs: the task graph exported earlier and a table of median durations keyed by task ID (project:target). Before computing anything, it validates the join. Every task in the graph must have a duration, every duration must map to a task in the graph, every edge must point at a known task, and every duration must be a positive number. If any check fails, the script exits non-zero and lists the offending IDs. This strictness is deliberate. A missing duration that silently defaults to zero shortens whichever chain it belongs to, and a renamed target produces an orphaned timing. Either error moves the critical path without any visible sign that something went wrong.

Next, the script sorts tasks topologically and fails if it finds a cycle. For each task, it computes finish time as the task's own duration plus the latest finish among its dependencies, and records which dependency set that maximum. To get the critical path, it walks back from the task with the latest finish.

Illustrative estimate with assumed medians. The API bundles the shared library from source, so api:build does not wait for shared-lib:build:

shared-lib:build  180s                      (off path)

api:build  100s ==> api:e2e  140s           (critical: 240s)

shared-lib:build is the slowest single task, but making it faster would not shorten the pipeline at all. The 240-second chain sets the floor.

This result assumes unlimited parallelism. On real agents with a fixed number of parallel slots, tasks that are off the critical path still compete for those slots. Treat the critical path as a lower bound on wall-clock time, not a prediction of it.

import json, sys
from graphlib import TopologicalSorter, CycleError

graph = json.load(open(sys.argv[1]))
deps = graph.get("dependencies")
durations = json.load(open(sys.argv[2]))
if not isinstance(deps, dict) or not isinstance(durations, dict):
    sys.exit("unexpected input shape: expected objects keyed by task ID")

tasks = set(deps)
errors = {
    "missing duration": tasks - set(durations),
    "orphaned timing": set(durations) - tasks,
    "unknown edge target": {d for ds in deps.values() for d in ds} - tasks,
    "non-positive duration": {t for t, s in durations.items()
                              if not isinstance(s, (int, float)) or s <= 0},
}
for kind, ids in errors.items():
    if ids:
        print(f"{kind}: {sorted(ids)}", file=sys.stderr)
if any(errors.values()):
    sys.exit(1)

try:
    order = list(TopologicalSorter(deps).static_order())  # deps come first
except CycleError as e:
    sys.exit(f"cycle in task graph: {e.args[1]}")

finish, via = {}, {}
for t in order:
    prev = max(deps[t], key=finish.get, default=None)
    finish[t] = durations[t] + (finish[prev] if prev else 0)
    via[t] = prev

t, path = max(finish, key=finish.get), []
while t:
    path.append(t); t = via[t]
print(" ==> ".join(reversed(path)), f"({max(finish.values())}s)")

A Python script that validates the join between task-graph edges and median durations, exits non-zero on any mismatch or cycle, then computes the longest-duration chain through the DAG.

Ranking candidates and diagnosing slow backend targets

Because the critical path is only a lower bound, rank tasks by expected cost per PR rather than raw duration. Combine three measures for each task: its uncached duration on the critical path, the fraction of affected CI runs that include it, and its cache hit rate when it does run. Multiply them: duration × run frequency × (1 − hit rate).

Illustrative estimate, assumed inputs: a 4-minute API build that appears in 90% of runs with a 20% hit rate costs about 2.9 minutes per PR. A 12-minute e2e target that appears in 30% of runs with a 90% hit rate costs about 0.4 minutes. The slower task matters less because the cache already absorbs it. Low hit rates on the 4-minute build also point to an input problem, which you can fix.

Once you have the ranking, check the top candidates for these causes:

Symptom Likely cause How to detect it in timing and graph data
Build reruns on test-only PRs Inputs include spec files or the root lockfile Cache misses correlate with commits touching only *.spec.* or the lockfile; inspect resolved inputs with nx show project
Hash matches but task still runs slowly Outputs undeclared or wrong path Cache hits recorded, yet duration stays close to the uncached duration; downstream tasks rebuild missing files
build and lint durations both high, similar Type-checking runs in both targets Both targets share a tsc-sized floor; removing type-aware rules or noEmit checks drops one of them
Integration/e2e duration scales with project count Databases or containers start serially per target Start offsets in the run summary are staggered with no gaps; no graph edge explains the ordering

Fixes that shorten the critical path, and their risks

Each fix below targets a task that sits on the critical path. Speeding up a task that has slack only reduces total CPU time.

Narrow namedInputs and inputs

If editing a README or a spec file invalidates build on a core library, every downstream build reruns. Define a production named input in nx.json that excludes test files, for example "!{projectRoot}/**/*.spec.ts". Then point build at production and ^production. This lowers the cache-miss rate on path tasks. It does not make any single task faster.

The risk is silent staleness. If you exclude a file that does affect output, such as a JSON schema, a codegen template, or an .env read at build time, Nx will restore a cached artifact that is wrong. Timing data will look better, not worse, so check every exclusion against what the build actually reads.

Split a large shared library

A monolithic shared library forces every dependent to wait for its entire build. Split it along real usage lines, such as shared-db and shared-http, so that services depending on only one part unblock earlier. The cost is more project.json files, and possibly import cycles that the old single project hid.

Remove unnecessary dependsOn edges

A blanket "dependsOn": ["^build"] on test serializes work. Tests that run from source through a TypeScript transformer often don't need it. Remove the edge per target, not globally. Remove it only after confirming that no task reads dist/ output. Otherwise you get intermittent failures that depend on scheduling order.

Split long test targets

Break a 12-minute test target into test-unit and test-integration so they can run on separate agents in parallel.

After each change, rerun uncached and recompute the path. A fix can move the bottleneck elsewhere instead of removing it.

{
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "sharedGlobals": [],
    "production": [
      "default",
      "!{projectRoot}/**/*.spec.ts",
      "!{projectRoot}/**/*.md",
      "!{projectRoot}/jest.config.ts"
    ]
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["production", "^production"],
      "cache": true
    },
    "test": {
      "inputs": ["default", "^production"],
      "cache": true
    }
  }
}

An nx.json where build hashes only production inputs, so spec and README edits no longer invalidate path builds, and test drops the default ^build edge because it runs from source.

Alternatives, limits, and impact on cost and engineering time

Because a fix can shift the bottleneck rather than remove it, the analysis has to be repeatable, and it helps to know where it stops being the right tool.

Approach What it tells you Blind spot
Total CI time only Whether things got worse Which task caused it
Sort by isolated duration Expensive tasks Slow tasks off the path still finish in time
Raise --parallel or add capacity Whether you are short on cores Does nothing for a serial dependency chain
Distribute across agents Spreads load Adds per-agent setup and artifact transfer, which the task graph does not model
Critical path The chain that bounds wall-clock time Assumes tasks start as soon as dependencies finish

That last assumption fails on low-core agents. Check whether tasks start well after their dependencies complete. If they do, they are waiting for a free slot, so the limit is capacity and not dependency order. Measure that queueing before you restructure the graph.

Uncached profiling runs also cost CI minutes, and they differ from day-to-day runs where most tasks hit the cache. Measure the worst case and a typical affected run.

Illustrative estimate (assumed numbers, not a benchmark): monthly profiling minutes = runs per month × uncached minutes per run × agents. With 4 runs, 40 minutes, and 3 agents, that is 480 minutes.

To make the analysis repeatable:

  • Run profiling as a scheduled or on-demand job.
  • Store the graph and timing files as artifacts.
  • Chart critical-path length over time.

This targeting also saves engineering time. A week spent speeding up a task that is off the path leaves wall-clock time unchanged. Work on the path shows up in the next uncached run.

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 timing analysis, 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.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

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.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • 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.