How nx run-many --parallel works and what it does not control

nx run-many --parallel=N sets one limit: the maximum number of Nx tasks the task runner will execute at the same time. A task here is a single target on a single project, such as api:test or shared-utils:build. Nx schedules tasks in dependency order and keeps at most N of them running.

It does not control what happens inside each task. Jest starts its own worker processes. Type checking can run in a separate process alongside the bundler. Many bundlers use their own thread pools. Each of these makes its own sizing decision, often based on the number of CPU cores it detects. Nx neither knows about these decisions nor limits them.

This guide is for backend and platform engineers who run Nx monorepos on small CI runners with little memory. Teams on these runners usually raise --parallel to shorten wall-clock time, because more tasks in flight means a shorter pipeline. That reasoning holds only while memory lasts. Total memory grows with N times M, where M is the number of inner workers each task starts. Nx enforces only the N.

+---------------------- CI runner memory ----------------------+
| OS + page cache | CI agent | Nx daemon                        |
+--------------------------------------------------------------+
| Task 1: [w1][w2]...[wM]                                      |
| Task 2: [w1][w2]...[wM]                                      |
|  ...                                                         |
| Task N: [w1][w2]...[wM]                                      |
+--------------------------------------------------------------+
  --parallel caps the rows (N); each tool picks its own M
#!/usr/bin/env bash
# Shows that N (Nx tasks) and M (workers inside each task) are two separate settings.
set -euo pipefail

# What the runner has. Inner tools often size their worker pools from the core count.
echo "CPU cores: $(nproc)"
free -m

# N only: Nx runs up to 3 test targets at the same time.
# Each Jest process still picks its own worker count (M) from the cores it detects,
# so worst case is 3 x M worker processes in memory at once.
npx nx run-many -t test --parallel=3

# N and M together: also cap Jest workers per task.
# --maxWorkers is a Jest option. Nx forwards flags it does not recognize to each task.
# Worst case is now 3 tasks x 2 workers = 6 Jest workers, plus each task's parent process.
npx nx run-many -t test --parallel=3 --maxWorkers=2

Running the same Nx test targets twice: the first command caps only the number of concurrent tasks (N), and the second also caps Jest workers inside each task (M), which bounds the N-times-M worker count.

Why memory, not CPU count, limits concurrency on small runners

On a small runner, the total footprint is N rows times M workers times per-worker memory, and that product hits the memory ceiling well before the cores are saturated. CPU contention only slows tasks down. Memory exhaustion stops them. When memory runs out, the kernel's OOM killer sends SIGKILL to a process, usually a large Node worker, and the processes around it keep running.

The symptoms rarely mention memory directly:

Symptom What you see Why it misleads
Exit code 137 Task fails with 128 + 9 (SIGKILL) Often reported only as "command failed"
Killed in output A single line, sometimes with no stack trace Easy to miss among interleaved parallel logs
Truncated output Logs stop mid-test or mid-compile Looks like a hang or a runner glitch
Intermittent failures Different task fails on each run Gets labelled a flaky test and retried

Containerized runners make this harder to diagnose. The memory limit is enforced by a cgroup, but /proc/meminfo and similar interfaces inside the container often report the host's total memory. A tool that sizes its worker pool from host memory may assume tens of gigabytes when the cgroup allows a few, and it will over-allocate without any warning.

The Nx cache also hides the problem. On a cache hit, Nx replays stored output and does not execute the task, so a warm pipeline runs only a few real tasks at a time. A cold run, such as after a lockfile change, a cache eviction, or on a new branch, executes every task at the full configured concurrency. That is often the first time the true peak is reached.

#!/usr/bin/env bash
# Compare what the OS reports with what the container may actually use,
# then force a cold run and check whether the kernel OOM-killed anything.
set -uo pipefail

# /proc/meminfo inside a container usually shows the HOST total.
host_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
echo "MemTotal from /proc/meminfo: $((host_kb / 1024)) MiB"

# The real ceiling is the cgroup limit (v2 first, then v1).
if [ -f /sys/fs/cgroup/memory.max ]; then
  limit=$(cat /sys/fs/cgroup/memory.max)            # "max" = no limit set
elif [ -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then
  limit=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes)
else
  limit="unknown"
fi
case "$limit" in
  ''|*[!0-9]*) echo "cgroup memory limit: $limit" ;;
  *)           echo "cgroup memory limit: $((limit / 1024 / 1024)) MiB" ;;
esac

# Skip the cache so every task really executes: this is the cold-run peak.
npx nx run-many -t test --parallel=2 --skip-nx-cache
status=$?

# A directly killed process exits 137 (128 + SIGKILL). Nx may surface a
# killed task only as a generic failure, so also check the kernel's counter.
[ "$status" -eq 137 ] && echo "exit 137: process received SIGKILL, likely OOM"
if [ -f /sys/fs/cgroup/memory.events ]; then
  grep '^oom_kill ' /sys/fs/cgroup/memory.events   # non-zero => OOM kills occurred
fi

exit "$status"

A CI diagnostic script that contrasts host memory from /proc/meminfo with the cgroup limit, forces a cold Nx run with the cache skipped, and checks for exit code 137 and the cgroup v2 oom_kill counter.

Measuring peak memory per task

Before you can size concurrency, you need a number for each kind of task, measured under the same conditions that cause trouble: no cache hits and nothing else competing for memory.

Isolate one task at a time

Pick a representative project for each target type: one of your largest libraries and one of your typical ones. Run each target alone with --skip-nx-cache. A cache hit replays stored output and tells you nothing about memory. Do this on the same runner image you use in CI, because Node's heap defaults and tool behaviour depend on available memory.

On Linux, wrap the invocation in /usr/bin/time -v (GNU time, not the shell builtin) and read the "Maximum resident set size" line, reported in kilobytes. One caveat matters here. That figure is the peak of the largest single process in the tree, not the sum across processes. A test runner that spawns its own workers will look smaller than it really is. For those targets, either force a single worker during measurement or sample total memory with a monitoring tool while the task runs.

Group by task type

Record results per target: lint, typecheck, test, build, e2e. The spread is usually wide. Typecheck and build on large projects tend to dominate, and e2e adds a browser on top of Node. Size concurrency for the heaviest type that can run in a given invocation, not the average.

Measure cold, and measure again

Warm local runs understate peaks. Measure from a clean checkout. Then repeat periodically and after major dependency or project-structure changes. Peaks drift upward as the codebase grows, and a limit that fit last quarter quietly stops fitting.

#!/usr/bin/env bash
# Measure peak RSS per Nx target, one task at a time, with no cache hits.
# Run on the same runner image as CI, from a clean checkout.
set -euo pipefail

PROJECTS=(big-lib typical-lib)            # one large, one typical project
TARGETS=(lint typecheck test build e2e)   # group results by task type
OUT=peak-memory.csv

export NX_DAEMON=false   # keep the daemon from running in the background during measurement
npx nx reset             # clear local cache and daemon state before starting

echo "project,target,exit_code,max_rss_mib" > "$OUT"

for project in "${PROJECTS[@]}"; do
  for target in "${TARGETS[@]}"; do
    tmp=$(mktemp)
    # GNU time (not the shell builtin); -o keeps its report separate from task output.
    # --skip-nx-cache forces a real run instead of replaying stored output.
    set +e
    /usr/bin/time -v -o "$tmp" npx nx run "$project:$target" --skip-nx-cache > /dev/null 2>&1
    code=$?
    set -e

    # Reported in KB. This is the largest single process, NOT the sum of the tree:
    # for test runners that spawn workers, force one worker or sample total memory.
    kb=$(awk -F': ' '/Maximum resident set size/ {print $2}' "$tmp")
    mib=$(( ${kb:-0} / 1024 ))
    echo "$project,$target,$code,$mib" >> "$OUT"
    rm -f "$tmp"
  done
done

# Heaviest task types first: size --parallel against these, not the average.
sort -t, -k4 -nr "$OUT" | column -t -s,

A bash script that runs each representative Nx target alone with the cache skipped under GNU time and records the maximum resident set size per project and task type, sorted heaviest first.

Sizing --parallel from measured memory: a worked example

The formula is straightforward: take the runner's total memory, subtract what the OS, the CI agent and the Nx daemon hold, then divide by the peak memory of your heaviest task and round down. Then reserve headroom, because your measured peak is a sample, not a ceiling.

The inputs below are assumptions chosen for illustration. This is an illustrative estimate, not a measured result:

  • Runner memory: 7 GB
  • OS and CI agent: 1.0 GB
  • Nx daemon: 0.5 GB
  • Usable memory: 5.5 GB
  • Headroom: 25%, leaving about 4.1 GB budgeted
Scenario Peak per task Raw slots (5.5 GB) With headroom (4.1 GB)
Single-process test task 1.2 GB 4 3
Same task with 2 inner workers (0.4 GB parent + 2 × 0.8 GB) 2.0 GB 2 2

Inner workers change the result. A test runner that forks its own workers multiplies memory inside one Nx task, and Nx counts that as a single slot. In this example, enabling two workers drops the safe --parallel from 3 to 2. Always size against the task's total footprint across all of its processes, not just the parent.

The cost of a conservative value is wall-clock time. On a warm cache, most tasks are restored rather than executed, so a lower --parallel barely registers. On a cold cache every task runs, the queue is long, and moving from 3 slots to 2 can stretch the pipeline noticeably. That is often the right trade, because a slower run that finishes is cheaper than a fast run that is killed by the OOM killer and has to be retried.

Implementing it: inner workers, heap limits, split invocations and config

The slot count is only half of the configuration, because many tools parallelize internally. Jest spawns its own worker processes, often one fewer than the number of reported cores. On a container that reports more CPUs than it can use, three Nx slots running Jest can turn into many Node processes. Cap these tools explicitly, for example with Jest's --maxWorkers set to 1 or 2 in CI. This keeps the per-task peak you measured roughly constant. Apply the same check to bundlers, type checkers, and test runners that fork workers.

Next, set NODE_OPTIONS=--max-old-space-size (in megabytes) slightly above each task's measured peak heap. A task that exceeds its limit then fails with a V8 heap error that names the culprit. Without a limit, the kernel kills whichever process it picks, and the logs only show an unexplained exit. The flag covers only the V8 heap, not native memory or child processes, so leave headroom. Setting it too low will fail tasks that would have finished, so derive it from measurements rather than guesses.

Split targets by weight. Run lint and typecheck in one run-many or affected step with a higher --parallel, and run build and e2e in a separate step with a lower value. Each invocation pays Nx startup and project-graph computation again, so keep the split to two or three steps.

Finally, keep a sensible workspace default for developer machines in nx.json, and pass --parallel on the CLI in CI. The flag overrides the default, so local and CI runs get different values from one shared config.

#!/usr/bin/env bash
# CI script: separate light and heavy targets, each with its own --parallel and heap cap.
# All numbers are placeholders. Derive yours from measured peak memory per task.
# nx.json keeps the developer-machine default (for example "parallel": 3).
# The --parallel flags below override that default in CI only.
set -euo pipefail

# Step 1: light targets. Small per-task heap, so more Nx slots fit in memory.
# Cap V8 heap slightly above the measured lint/typecheck peak (in MB).
NODE_OPTIONS="--max-old-space-size=1024" \
  npx nx affected -t lint typecheck --parallel=6

# Step 2: unit tests. Jest forks its own workers, so cap them.
# Otherwise each of the 3 Nx slots may start several Jest workers.
# Nx forwards --maxWorkers to the Jest target as an option override.
NODE_OPTIONS="--max-old-space-size=2048" \
  npx nx affected -t test --parallel=3 --maxWorkers=2

# Step 3: heavy targets. Large per-task heap, so fewer slots.
# If a build exceeds this cap, V8 reports a heap out-of-memory error for that task.
# Without a cap, the kernel OOM killer may terminate an unrelated process.
# Setting the cap too low fails builds that would have finished, so use measured peaks.
NODE_OPTIONS="--max-old-space-size=4096" \
  npx nx affected -t build e2e --parallel=2

# Each step pays Nx startup and project-graph computation again,
# so keep the split to a few steps.

A CI script that runs light and heavy Nx targets in separate affected steps, each with its own --parallel value, a V8 heap cap from NODE_OPTIONS, and capped Jest workers, while nx.json keeps the local default.

When to tune concurrency, when to avoid it, and alternatives

When tuning pays off

Tuning --parallel is worth the effort when CI jobs keep failing with exit code 137 and you cannot change the runner size. It also matters when task weights are mixed, for example a few heavy builds alongside dozens of light lint or test tasks. In that case a single CPU-based value either wastes the light tasks or overcommits memory on the heavy ones.

When to leave it alone

Skip tuning if tasks rarely run at the same time. Long dependency chains already serialize most of the work, so a higher --parallel has nothing to run. Skip it if the pipeline is already fast enough that saving a minute changes nothing. And skip it if your measured peaks leave room for only one or two concurrent tasks. The setting cannot help much there.

Alternatives

Approach What it changes Trade-off
nx affected instead of run-many Runs only projects touched by the change Depends on a correct base commit; full runs still happen on main
Remote or shared cache Skips tasks whose inputs have not changed Requires cache storage and trustworthy task inputs and outputs
Spread tasks across more runners Each runner carries less memory load More orchestration, plus per-job setup overhead
Larger runner Raises the memory ceiling directly Higher cost per minute; does not fix wasted work

Start with the first two rows. They reduce how much work exists at all. Raising concurrency only changes how fast the same work gets done. A task that is skipped uses no memory, so cutting total work often helps more than any --parallel value.

Impact on cost and engineering time

The most expensive part of an out-of-memory kill is rarely the rerun. It is the engineer who reads a killed test worker or a truncated build log as a real test failure, bisects a commit that changed nothing relevant, and then retries until the job passes. The kill depends on which tasks happened to overlap, so it looks random, and random failures teach people to distrust red builds. Sizing --parallel from measured peaks removes most of that overlap risk, so a failure points to a real problem again.

Illustrative estimate (assumed numbers, not a benchmark): weekly cost = OOM failures per week × (rerun minutes + investigation minutes). Assume 6 failures, 15 minutes of waiting on reruns and 30 minutes of investigation each: 6 × 45 = 270 engineer-minutes, about 4.5 hours a week. Compare that with the extra cost of a larger runner over the same build minutes.

When to stop tuning and resize

  • Fewer than about two safe slots. Compute (runner memory − OS and runner overhead) ÷ peak of the heaviest target type. If the result is below about 2, you are effectively running serially. Further tuning recovers little, and more memory is the cheaper fix.
  • Measurements keep going stale. If dependency upgrades or new projects shift peaks often enough that you re-measure every few weeks, the tuning effort is now recurring. Buy headroom instead.

Keep the value from drifting

Re-measure the heaviest target type on a fixed schedule, such as monthly, and after major toolchain upgrades. Record the date and the measured peak in a comment next to the setting. If the new peak uses up your safety margin, lower the value or resize the runner before the OOMs return.

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 parallel tasks ci, 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.