‹ Blog
Juri Strumpflohner
Juri StrumpflohnerJuri Strumpflohner

Nx 23.3 Is Here: Task-Based Affected, Faster Graph Creation and Agent-Friendly Output

The simplest way to speed up your CI runs is to run only what's necessary. That's what nx affected does, and Nx 23.3 makes it a lot more precise with task-based affected.

And this is just the beginning. We're hosting a product event on Oct 22nd with a big announcement about scaling CI for the flood of PRs and tests your agents produce. Make sure you register!

Now, on to the highlights of Nx 23.3.

Task-based affected

nx affected has always worked at the project level. You change a file, Nx figures out which project owns it, and every project that depends on that project is affected too. Then it runs the requested target on all of them. This works, but it is the more defensive approach so it happens to run more than it sometimes needs to. Change one spec file in a shared library and every dependent project re-runs its build task, even though none of them reads that spec file. In the Nx repo a single change like that used to select 45 test tasks.

Starting with 23.3, task-based affected selects tasks based on whether your change reaches what each task reads.

Now changing that one spec file runs the test task of the project it lives in and nothing else. The effect is biggest on targets with narrow inputs like build, test and e2e, which is usually where most of your CI time goes. Tasks that read most of a project, like lint, narrow very little.

NX_LEGACY_AFFECTED=false nx affected -t test

Task-based affected is opt-in in 23.3, and we plan to make it the default in Nx 24. Give it a try on your CI and let us know how it behaves on your workspace.

How Nx decides a task is affected

The idea is the same one behind caching. A task's inputs define its behavior, so when its inputs change, its behavior changes and the task is affected. That holds for every task, cacheable or not.

Nx starts from the files your change touched, which it gets from git as before. Every task that declares one of those files as an input is touched directly. A touched task produces different outputs, so every task that reads those outputs is affected too, and Nx keeps following that through the whole task graph. Tasks that end up unaffected haven't changed behavior, so Nx drops them from the task graph and doesn't run them.

A task's inputs can come from three places:

  • Its own files - Anything its inputs cover in its own project, like {projectRoot}/**/*.
  • A dependency's files - Source files of the projects it depends on, through ^ inputs like ^production or ^default.
  • A dependency's build output - The output of a task it depends on, through dependentTasksOutputFiles.

dependsOn doesn't decide whether a task is affected. It only controls the order tasks run in. Say app:test has dependsOn: ["^build"] but its inputs only cover {projectRoot}/**/*.

a) With project-level affected, a change in ui runs app:test because app depends on ui.

b) With task-based affected it doesn't, because nothing app:test reads has changed. The dependsOn edge only makes sure ui:build runs first when both are selected.

Affected explain

Most workspaces won't notice, since the default inputs include ^production or a similar named input, so dependency changes still reach the task. Where it matters is targets with hand-written inputs. The most common case is a target with "cache": false and its own inputs. If it should re-run when a dependency changes, add ^default or dependentTasksOutputFiles to its inputs. Read more about named inputs and dependentTasksOutputFiles in our docs.

When a task shows up (or doesn't) and you're not sure why, --explain tells you:

NX_LEGACY_AFFECTED=false nx affected -t build --explain
 NX   3 out of 12 build tasks are affected

Changing 1 file touches 1 build task. Pass --verbose to list each with its reasons.

  libs/ui/src/index.ts:
    - ui:build

Touching those tasks changes outputs read by 2 build tasks. Pass --verbose to list each with its reasons.
  - admin:build
  - app:build

The first group lists the tasks whose own inputs your change touched, grouped by changed file. The second lists the tasks that read their outputs. Pass --verbose to see each task's reasons, like the pattern a file matched or whose outputs it reads, and --explain=stdout to get the same data as JSON. --explain exits without running anything, and nx show projects --affected -t build --explain works the same way.

More intuitive hashing

Selecting tasks by what they read only works if the task hash covers everything a task reads. 23.3 closes two gaps there.

e2e tasks follow the server they test. An e2e task tests the running app, and the dev server is what runs it, which behaves like the build. Nx plugins used to cover this by giving e2e tasks the app's build inputs. With 23.3, Nx follows that chain itself. A task that depends on a continuous task, like e2e on serve, includes that task's inputs in its hash. The serve, dev, preview and start targets of nine Nx plugins now infer the same inputs as their build target (#37017, #37024).

This matters most for task-based affected. With project-based affected, an e2e project usually had an implicit dependency on the app project. Task-based affected ignores implicit dependencies and only looks at inputs, so this is what connects the two. Touching the inputs of a serve task now affects the e2e tasks that depend on it.

Gitignored files can now be task inputs. A fileset input used to see only files that git tracks (following .gitignore and .nxignore). A generated .env, a downloaded schema or build output could never be part of a task hash. Add includeIgnored: true and Nx hashes what's on disk (#37025):

{
  "inputs": [
    { "fileset": "{projectRoot}/generated/**/*.json", "includeIgnored": true },
    { "fileset": "!{projectRoot}/generated/**/*.map", "includeIgnored": true },
    { "fileset": "{workspaceRoot}/.env.generated", "includeIgnored": true }
  ]
}

A path can also name a file that doesn't exist yet, and the hash changes when it appears. Use nx show target <project>:<target> inputs to see which files matched.

See gitignored files in the inputs reference, which also covers how this relates to dependentTasksOutputFiles.

Batched Oxlint runs

In 23.2 we added the @nx/oxlint plugin. It inferred an oxlint . command per project, so nx run-many -t lint started one Oxlint process per project, and with the module boundaries rule on, each of those processes loaded the project graph again. Oxlint itself is so fast that this startup cost dominated, and on CI it added up quickly.

With 23.3, the inferred lint target uses a new @nx/oxlint:lint executor that runs in batch mode by default. When a command lints several projects, like nx run-many -t lint or nx affected -t lint, Nx starts a single Oxlint process for all of them (#36827). Each project still keeps its own result, terminal output and cache entry, so nx affected picks projects individually and a cache hit on one project doesn't depend on the others.

Some quick stats on a local machine workspace with 50 libraries and 350 TypeScript files, with the module boundaries rule on:

RunTime
Nx 23.2, one Oxlint process per project16.5s
Nx 23.3, batched (default)3.0s
Nx 23.3, fully cached0.86s
oxlint . at the root, without Nx1.5s

You might wonder where the remaining difference to a plain oxlint . at the root comes from. Oxlint itself takes the same 1.2 seconds in both runs. The extra 1.5 seconds on the Nx side are a fixed cost: starting the CLI (0.25s), creating the project graph (0.57s), hashing the tasks (0.14s), starting the worker that runs the batch (0.3s) and recording a result for each of the 50 projects (0.3s). That cost doesn't grow with the amount of code you lint, and it's what gives you per-project caching and nx affected in return. A fully cached run (0.86s) is already faster than linting the whole workspace at the root, and when only a few projects are affected, Nx lints just those.

Options you set on the target or pass on the command line are forwarded to Oxlint, so nx run-many -t lint --type-aware runs Oxlint with --type-aware. Since one process lints the whole batch, it runs with the first project's options, and Nx warns you if another project in the batch sets different ones. The Oxlint plugin docs cover the details.

Agent-friendly output with the summary style

In 23.2 we reduced the log output by collapsing successful tasks to a single line. 23.3 goes a step further for agents with --output-style=summary. It prints a single summary with the task counts and, for each failure, the path to its full log:

nx run-many -t test --output-style=summary
 NX   34 tasks: 31 succeeded, 29 cached, 1 failed, 2 skipped

✖  nx run js:test  (exit 3)
   full log: /repo/.nx/cache/terminalOutputs/9f2c…

Re-run with --output-style=static to inline logs.

Nx inlines no task output at all, so the size of the output depends on how many tasks failed, not how much they logged. The agent reads the log file of the failing task when it needs the details. Nx picks this style by default when it detects that an AI agent is running it and you haven't set an output style yourself (#36703).

For this to work, every task now writes its terminal output to disk when it finishes, including tasks with cache: false and tasks in batch mode, which previously left no log file behind.

Faster project graph creation

On a large workspace, every nx command spends a good chunk of its time before any task runs. Nx walks the workspace, hashes files and asks each plugin to infer projects. With 23.3, project graph creation in a large enterprise workspace got up to 60% faster on cold runs and up to 80% faster on warm runs. The exact gain depends on your repo and the machine it runs on.

Where the speedup comes from
  • One workspace walk shared across processes - Plugin workers and parallel nx processes used to each walk and hash the whole workspace, 9 to 15 times per graph creation on our own CI. Now one process walks and the others reuse its result (#36980).
  • Cached plugin capabilities - Parallel nx affected runs on CI no longer load every plugin just to read its file patterns (#36999).
  • Faster glob matching - Nx now resolves glob groups by prefix. On one customer repo, that step alone took 40 seconds of a 70-second ESLint project inference (#37068).
  • Faster native planning and hashing - @kylecannon made the Rust side of task planning and hashing do less work per task (#36992, #36994), and trimmed the task graph sent to task workers (#37015).
  • Leaner plugin inference - The package.json, ESLint and Docker plugins do less work when inferring tasks (#37089, #37125, #37124).
  • Cached TypeScript project references - @jcaracciolo cached the TypeScript project reference expansion in @nx/js/typescript (#37003).
  • Memoized Vitest tsconfig lookups - @zlajson memoized the tsconfig lookups in the Vitest plugin (#37027).

Get ready for Nx 24 with convert-to-inferred

Nx 24 will remove the built-in executors that have an inferred task replacement. That's 37 executors across 15 packages, including @nx/cypress, @nx/jest, @nx/playwright, @nx/vite, @nx/vitest, @nx/webpack, @nx/rspack, @nx/storybook, @nx/eslint, @nx/next, @nx/expo and @nx/react-native.

The executor API itself stays. You can keep writing and using your own executors. Nx 24 will only remove some of the built-in executors in the plugins we ship.

A generator migrates your projects for you:

npx nx g infer-targets

We wrote up the whole story, including how to migrate, in Look Mum, No Executors.

Framework and tooling updates

If you use the Nx plugin for Cypress (@nx/cypress), 23.3 supports Cypress 16. It ships 16 migrations that rewrite options and commands Cypress removed, so nx migrate --run-migrations handles most of the upgrade (#36953).

Angular users on @nx/angular get NgRx 22 support with a migration as well (#36950).

Angular Rspack SSR now runs on the @angular/ssr application engine, the same setup the Angular CLI scaffolds (#36298). @skrtheboss made incremental rebuilds a lot faster when skipTypeChecking is on, from 20 to 55 seconds down to 1 to 3 seconds on a 6k-file app (#36972).

If you're using the Nx plugin for Vitest (@nx/vitest), 23.3 supports Vitest 5. New projects are generated with Vitest 5, and nx migrate moves existing workspaces from Vitest 4 to 5, including a migration that rewrites code affected by Vitest 5's breaking changes (#37142). nx migrate holds the version bump back for now if your workspace uses Vite below 6.4, Angular's Vitest integration (@angular/build or Analog) or Vitest browser mode, since those don't support Vitest 5 yet. @francesco-agnoletto laid the groundwork by widening the peer range to Vitest 5 (#37055). Nx now also picks up Vitest setup files outside the project root as task inputs (#36920).

Nx 23.3 supports pnpm 12. pnpm 12 changed how it reads configuration: --config.<setting> flags now mean something else, so pnpm 12 silently ignored the settings Nx passes when nx add, nx init and nx migrate install packages. Nx now also sets them as PNPM_CONFIG_* environment variables, which is where pnpm 12 reads them, and keeps the flags for pnpm 11 (#37116). @cogwirrel made Nx read pnpm 12's minimum release age policy directly (#36915). @yharaskrik and @skrtheboss made pruned lockfiles keep the package-manager document and the minimumReleaseAge setting that pnpm writes into the lockfile (#36947, #36900).

For .NET, @nx/dotnet now infers the right inputs and keeps restore outputs out of the cache (#36958), we fixed the CI workflow generator, which now detects Microsoft.Testing.Platform (#36981), and there's a new basic .NET example in the Nx repo (#36701). If you want to see .NET and TypeScript working together in one workspace, Craigory's post on full-stack type safety with .NET and OpenAPI is a good start.

Thanks to the community

A huge thanks to the 20+ outside contributors who shipped fixes and features in 23.3. Besides the ones mentioned above:

If you've been meaning to contribute, the community label is a good place to start. These folks already did. You can be next.

How to update Nx

As always, updating to the latest version of Nx is straightforward:

npx nx migrate latest

This analyzes your workspace and creates a migration file with all necessary updates. Review the changes, then apply them:

npx nx migrate --run-migrations

New to Nx? Create a fresh workspace with npx create-nx-workspace@latest, or run nx init inside an existing npm/pnpm workspace to start using Nx with it. Head over to the Getting Started guide for a walkthrough.

Learn more