Nx Cloud CI is in private betaJoin the beta
‹ Blog
Juri Strumpflohner
Juri StrumpflohnerJuri Strumpflohner

5 Ways We Solve the CI Bottleneck

5 Ways We Solve the CI Bottleneck

CI has become the bottleneck. What was initially just an issue for large-scale enterprise monorepos is now affecting everyone. Coding agents push far more often than humans do (Anthropic reported 25x more CI jobs in six months), and every push starts a full CI run.

Trying to fix this by switching to providers that promise faster, more powerful machines is not gonna help in the long run.

The only way to fix it is to use a deep understanding of your codebase to run CI more efficiently, and to run less of it. Here's how Nx Cloud CI does it.

Critical-path scheduling

Running CI means running a series of tasks. Some of these tasks are independent, like linting or unit tests, while others form chains: a shared package build needs to run before the specific UI or application build, which in turn needs to run before an application e2e test can run.

Most CI setups don't care and just process tasks in the order they come in. Nx Cloud CI, on the other hand, knows exactly which tasks are about to run, how they relate to each other, and how long each task took in earlier runs.

This information allows it to schedule tasks in a highly optimized way. Tasks that unblock future tasks run first, and so do long-running tasks that are hard to distribute. Other, smaller independent tasks fill in the gaps and thus help improve overall compute utilization.

The same 13 tasks on 3 workers. Starting the build chain first brings the wall clock down to the length of the chain.

Dynamic task packing

Having an efficient scheduling algorithm is the first part of a smart CI setup. The next question is how you pack these tasks onto multiple machines while optimizing for throughput and utilization.

Why is high compute utilization important? Because you pay for VM-minutes, not for used compute. A VM-minute costs the same whether the machine is at 100% CPU or waiting on a single slow task.

Hence, if you can pack more compute into the same or even fewer VM-minutes, you save money while having a faster CI run!

Nx Cloud CI records the CPU and memory profile of every task as it runs. When a pipeline runs and a worker has free capacity, it gets assigned the task that fits that compute budget.

Four tasks on one worker. Their CPU and memory profiles from earlier runs decide what can run side by side.

A lint task that needs one core can run next to a build that needs four. A memory-intensive e2e suite, instead, lands on its own machine.

In a more traditional, static CI config you'd have to decide on the parallelism up front and have a fixed matrix of which task goes on which machine. That has to account for the worst-case scenario because you have to make sure you don't go over compute capacity. This leads to low utilization during your average run.

Nx Cloud CI with dynamic task packing, on the other hand, does this at run time based on data from previous CI runs. This leads to much denser task scheduling and thus higher utilization.

Lane height is CPU. Without packing, a light task still holds a whole slot. With packing, the same tasks fit on 3 workers instead of 4.

Dynamic worker scaling

Packing makes every machine count. The next question is how many machines a run needs. Your CI should never run all the tasks on every PR. That's almost certainly a waste of compute. If you use something like task-based affected, you'll have a vastly different number of tasks to process for each PR.

Pick a changed file. Nx checks the inputs of each task, so a changed spec file only reruns ui's test and lint, while a change to ui's source reaches its dependents.

So when a PR only touches a handful of projects, you might end up with only a few tasks to run that can easily be handled by one or two machines, vs. the 10 you might need in a worst-case scenario.

Nx Cloud CI provides a feature to dynamically scale workers based on how many tasks need to be processed.

.nx/workflows/ci.yaml
jobs:
  main:
    workers:
      small: 3
      medium: 6
      large: 8
    steps:
      - run: ...

You define how many workers to use for small, medium and large changesets. Since Nx Cloud CI knows which tasks are affected and which are already cached, it can then start the corresponding set of workers to process the tasks that need to run.

Pick a change size. The small workers start with the pipeline. Once the first Nx command reports the work, Nx Cloud picks a size and adds only the workers it needs.

Ultracache

So far, this was about running CI more efficiently. The biggest lever to make CI faster and save costs, though, is to run less. What doesn't need to run, shouldn't run. Remote caching in combination with task-based affected plays a major role here.

To apply caching to tasks, you need to know the inputs and outputs of that task. Most build tools either have you define these manually or statically infer them from the project graph. This is suboptimal, though, as they might be too coarse-grained and thus invalidate the cache when they shouldn't (leading to fewer cache hits) or be entirely wrong, leading to false positives.

Nx Cloud CI uses Ultracache, which doesn't use a static configuration but rather records which files each task reads and writes while it runs on CI. These observed reads and writes become cache inputs and outputs.

Ultracache records the files shop:test reads, including other projects and root config, and stores them as its inputs. Change a file the test never read and it is a cache hit.

The result of using Ultracache:

  • correct and always up-to-date cache configuration
  • higher cache-hit rates

Fine-grained e2e test splitting (Atomization)

E2e tests have gained quite some popularity and importance with AI agents. First, they verify actual user behavior, which makes them a valuable indicator of whether things still work the way they should. Second, AI agents are really good at writing them.

There's just one problem: e2e tests tend to be extremely slow and become a major bottleneck in CI.

The techniques mentioned so far, such as dynamic task packing and Ultracache, are the building blocks for a smarter, faster CI. But they can't help as much if your e2e tests run as one single large task:

  • Dynamic task packing can't spread it across machines and has a hard time scheduling other tasks around it, which leads to much poorer machine utilization overall.
  • With simple remote caching it's all or nothing. If you hit the cache, you're lucky. If not, the entire e2e suite runs again.

Nx Cloud CI solves this with fine-grained e2e test splitting, which we call Atomization.

Atomization splits large test targets into one task per test file. The Nx plugins for Playwright, Cypress, Jest, Vitest and Gradle do this automatically. Once you have fine-grained tasks, the mechanisms we discussed earlier can schedule them efficiently.

If you combine this with Ultracache, you even get fine-grained caching at the single spec level. All it takes is to let your agent add a directive to the e2e tests.

One 18-minute e2e task, atomized into one task per spec file. On the next PR, only the tasks that read the changed file rerun, and only one spec is among them. Everything else comes from the cache, so the run needs fewer workers.

Benchmarks

We run the same monorepo benchmark on Nx Cloud CI and on GitHub Actions with Nx remote caching. That baseline is already an optimized pipeline. The benchmark measures a feature change, a large change and a full rebuild, weighted by how often each happens in a typical workspace.

Nx Cloud CI vs GitHub Actions with Nx remote caching, on a 511-project monorepo. Weighted as 80% feature changes, 17% large changes and 3% full rebuilds.

Nx Cloud CI finishes 10.6x faster and uses 79% less compute than GitHub Actions.

Customer workspaces show the same kind of savings. In workspaces whose CI was already heavily tuned, dynamic task packing alone cut VM-minutes by 35 to 50%.

Putting it together

None of these techniques needs a faster machine. Critical-path scheduling, dynamic task packing and dynamic worker scaling run your CI more efficiently. They get more out of every VM-minute and start only the machines a PR needs. Ultracache and Atomization let you run less, because only the work a change affects actually runs.

Nx Cloud CI is currently in private beta. Sign up to get early access.

Learn more