What is the Nx Atomizer and how does it work?

The Atomizer is a feature of Nx's inferred-task plugins. You don't hand-write a target per test file. Instead, you give the plugin a CI target name in its options (Jest exposes this as ciTargetName, for example). When Nx builds the project graph, the plugin scans the project's test files. It then infers a parent CI target plus one child task per file.

The parent target has no work of its own. It depends on every child task, so running it means "run all of them." Each child is a normal Nx task with its own inputs, outputs and cache entry. The scheduler treats children like any other task: it can spread them across workers, skip the ones whose inputs haven't changed, and rerun a single failed file without repeating the rest.

The original target is untouched. Locally you still run test as one process, with watch mode and the fast startup that comes from a single runner instance.

  local dev                      CI
  ─────────                      ──
  test (unchanged)               test-ci  (parent, no work)
  one runner process               │
  all files together               ├─ test-ci--src/users.spec.ts   → worker 1
                                   ├─ test-ci--src/orders.spec.ts  → worker 2
                                   ├─ test-ci--src/billing.spec.ts → worker 1
                                   └─ test-ci--src/auth.spec.ts    → worker 3

The unit of splitting is the test file, not the individual test case. A 900-line spec file with 200 cases is still one task, so file layout sets your ceiling for parallelism.

Plugin option names and the exact inferred behavior have changed across Nx releases. Before relying on them, check the plugin documentation for the version you run.

 LOCAL DEV                CI (inferred by the plugin)
┌────────────────────┐    ┌──────────────────────────────┐
│ test (unchanged)   │    │ test-ci  (parent, no work)   │
│ one runner process │    │ depends on every child task  │
│ all files together │    └──────────────┬───────────────┘
│ watch mode         │                   │ one child task per test file
└────────────────────┘                   │
       ┌─────────────────────────────────┘
       ├─► test-ci--src/users.spec.ts   ──► worker 1
       ├─► test-ci--src/orders.spec.ts  ──► worker 2
       ├─► test-ci--src/billing.spec.ts ──► worker 1
       └─► test-ci--src/auth.spec.ts    ──► worker 3

  each child task: own inputs, outputs and cache entry
  inputs unchanged ──► cache hit, task skipped
  file fails       ──► rerun that one task only
  granularity: one task per test FILE, not per test case

With a CI target name configured, the plugin infers a work-free parent CI target that fans out to one cacheable, independently retryable child task per test file across workers, while the original test target stays a single local process.

Why does splitting tests improve caching, not just CI parallelism?

With a single test target, Nx computes one hash over everything the target declares as input. Edit one service file and the hash changes, so every test in the project reruns. Atomized per-file tasks each get their own hash. A per-file task can be a cache hit while its neighbours rerun, but only if its hash actually excludes the files it doesn't depend on.

inputs and namedInputs in nx.json set that scope. You might define a named input testSupport as ["{projectRoot}/test/fixtures/**/*", "{projectRoot}/migrations/**/*", "{projectRoot}/test/helpers/**/*", "{workspaceRoot}/.env.test"], then set the test target's inputs to ["default", "^production", "testSupport"]. Nx then knows those files belong in the hash, alongside the project's own sources and the production inputs of its dependencies.

There are two ways to get this wrong. If you leave out fixtures, migrations or env files, a changed seed file doesn't change the hash, and Nx replays an old passing result for a test that would now fail. If you declare too much, for example by keeping default (which covers every file in the project) on each per-file task, any edit invalidates all of them. You pay the setup cost of many tasks and get no more cache hits than one target would give you.

Input set Example Effect on per-file tasks
Under-declared Spec file and source only Stale passes after fixture, migration or env changes
Over-declared Entire project plus workspace root Any edit invalidates every task, so there is no cache benefit
Well-scoped Spec, the code it imports, shared test support files Unaffected files hit cache, and shared-file changes correctly rerun
{
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "sharedGlobals": [],
    "production": [
      "default",
      "!{projectRoot}/**/?(*.)+(spec|test).ts",
      "!{projectRoot}/test/**/*"
    ],
    "testSupport": [
      "{projectRoot}/test/fixtures/**/*",
      "{projectRoot}/migrations/**/*",
      "{projectRoot}/test/helpers/**/*",
      "{workspaceRoot}/.env.test"
    ]
  },
  "targetDefaults": {
    "test": {
      "cache": true,
      "inputs": ["default", "^production", "testSupport"]
    }
  }
}

An nx.json that declares a testSupport named input for fixtures, migrations, shared helpers and .env.test, and adds it to the test target's inputs next to the project's own files and its dependencies' production inputs.

Who should use the Nx Atomizer, and when should you avoid it?

The Atomizer pays off when a suite is slow because it has many test files that each take a meaningful amount of time, such as integration specs that hit a database, exercise an HTTP layer, or run migrations. Backend and platform teams often have suites like this. If each file can run on a clean process without depending on state left by another file, every file becomes a separately cacheable and schedulable task. Splitting then helps both cache hits and CI parallelism.

Avoid it in three cases:

  • Many small, fast files. Each task pays process start, framework boot and module loading. When a file runs in a fraction of that time, setup dominates and total compute goes up.
  • One huge bottleneck file. Wall-clock time can't drop below the longest task, so splitting everything else changes little.
  • Isolation you can't fix yet. If files share fixtures, ports or database rows, separate tasks will produce failures that depend on ordering and timing.

These alternatives may fit better:

Approach Caching granularity Setup overhead Isolation requirements Operational complexity
Atomizer (task per file) Per file Paid per file Strict, cross-process Moderate
Single process, in-runner worker pool Whole suite Paid once Per worker Low
Runner's built-in sharding Whole suite (or per shard if configured) Paid per shard Per shard Low to moderate
Merging small files Per merged group Amortised Within the group Low
Manually splitting large files Per resulting file Paid per new file Within each new file Ongoing maintenance

You can combine these. Splitting the bottleneck file by hand and merging the tiny files can turn a poor fit into a good one.

How do you audit and isolate a backend suite before splitting it?

Before handing a suite to the Atomizer, assume it is coupled until you prove otherwise. A sequential run hides coupling because only one process ever touches shared state. Check for:

  • Shared database state. Tests that write rows other tests read, or that truncate tables mid-run.
  • Fixed ports. Two tasks binding 3000 or 5432 on one runner will collide.
  • Global setup and teardown. Hooks that start a server once or drop the schema at the end assume they own the whole run.
  • Test-order dependencies. A file that passes only after another file has run.
  • Cross-file seeding. Fixtures created in one file and consumed in another.

To isolate tasks, give each one its own schema or database, assign ports dynamically or derive them from a task identifier, and wrap each test in a transaction that rolls back. You then need to choose between per-task containers and a shared service with namespaced data. Per-task containers isolate more completely, but every task pays the startup cost. A shared service starts faster, but it relies on every test respecting its namespace. The example below derives a database name and port from a worker or task ID.

Parallel tasks also compete for the same infrastructure: database connection limits, container daemons, rate-limited third-party sandboxes, and runner memory. Size these for peak concurrency, not for a single run.

Finally, expect intermittent failures that the sequential run was masking to surface. Budget time to fix them before you enable the Atomizer broadly.

// test/task-env.ts: import from the test runner's setup file.
import { createHash } from 'node:crypto';

// Identify the task. TEST_TASK_ID is set by your CI or task wrapper (for
// example, the atomized target name). Fall back to Jest's worker ID locally.
const taskId =
  process.env.TEST_TASK_ID ?? `worker-${process.env.JEST_WORKER_ID ?? '1'}`;

// A stable hash maps the same task to the same name and port on every rerun,
// which makes leftover state from a failed run easy to find and drop.
const hash = createHash('sha256').update(taskId).digest('hex');
const slot = parseInt(hash.slice(0, 8), 16);

// Postgres identifiers allow lowercase letters, digits and underscores, up to
// 63 bytes. The hash goes first so truncation never removes it.
const readable = taskId.toLowerCase().replace(/[^a-z0-9]+/g, '_');
export const dbName = `test_${hash.slice(0, 8)}_${readable}`.slice(0, 63);

// Derive the app port from a reserved range so parallel tasks on one runner
// don't all bind 3000. Two task IDs can still hash to the same slot. If a
// collision would be costly, have the server listen on port 0 and read back
// the OS-assigned port instead.
const PORT_BASE = 20000;
const PORT_RANGE = 10000;
export const appPort = PORT_BASE + (slot % PORT_RANGE);

// One shared Postgres service with a database per task. Placeholder
// credentials only. Global setup creates dbName, and teardown drops it.
export const databaseUrl =
  `postgres://test_user:test_password@localhost:5432/${dbName}`;

process.env.DATABASE_URL = databaseUrl;
process.env.PORT = String(appPort);

Derives a stable database name and port for each test task from a task or worker identifier, so parallel tasks share one Postgres service without colliding on data or ports.

How much does per-task setup cost? A worked estimate

Every atomized task is a fresh process. It pays again for process start, TypeScript transpilation, module loading, and any global setup such as migrations, seed data, or container health checks. A single long-running process pays these costs once.

Illustrative estimate (assumed numbers, not a benchmark). The inputs are N = 200 files, t = average file time, S = 8s fixed setup per task, and P = 10 workers. The baseline is one process running the files serially.

  • Single process: compute = S + N·t, and wall-clock ≈ compute
  • Atomized: compute = N·(S + t), and wall-clock ≈ max(N·(S + t) / P, S + t_max)
Scenario Compute Wall-clock
Single process, t = 20s 8 + 4,000 = 4,008s ~67 min
Atomized, t = 20s 200 × 28 = 5,600s ~9.3 min
Single process, t = 2s 8 + 400 = 408s ~6.8 min
Atomized, t = 2s 200 × 10 = 2,000s ~3.3 min
Atomized, t = 2s, 10 files per task 20 × 28 = 560s ~56s

These figures are illustrative and follow only from the assumed inputs above; your own setup cost and file times will differ. Under those assumptions, with an assumed 20s per file, splitting adds about 40% more compute and cuts the wait by roughly 7x. With an assumed 2s per file, setup dominates: in this illustrative model compute grows about 5x, and the wall-clock gain is modest. Grouping small files restores the balance. The cost is that a change to any file in a group invalidates the cache for the whole group.

The S + t_max term sets the floor. If one file takes an assumed 180s, the run cannot finish in under 188s no matter how many workers you add. Split that file before you add machines.

In practice, splitting turns CI minutes into engineering time. In the illustrative 20s case, the assumed inputs work out to about 26 extra runner-minutes per uncached run, and each engineer waits about 58 fewer minutes for feedback. Cache hits on unchanged tasks reduce the compute side further. To get real numbers, substitute your own measured setup time, file durations and worker count into the formulas above.

How do you enable the Atomizer and wire it into CI?

The Atomizer is configured on the inferred-task plugin entry in nx.json, not on individual projects. In the plugins array, the entry for your test runner's plugin takes an options object. Setting the CI target name there, commonly ciTargetName with a value such as test-ci, tells the plugin to generate one task per test file plus a parent target that depends on all of them. Option names have changed across plugins and releases, so check the schema or documentation for the version you have installed before you copy anything.

Confirm the result before you touch the pipeline. Run nx show project <name> and check that the parent target exists and that each spec file has its own child target with inputs and outputs. If the file list is empty or incomplete, the plugin's include patterns don't match your layout, and CI would pass without running anything.

In CI, run nx affected -t test-ci so that only projects affected by the change are scheduled. Then use --parallel to set how many tasks run at once on each machine. Size that value to the CPU and memory that one test process needs, not to the number of cores. To spread work across machines, use distributed execution so that the scheduler assigns individual file tasks to workers, instead of splitting by project.

To prevent drift, keep flags, environment variables and setup in the runner's config file, not in pipeline YAML. Both targets are inferred from that same config. A developer can then run a single child target locally and reproduce an isolation failure that only appears in CI.

npx nx show project api

npx nx affected -t test-ci --parallel=3

npx nx run api:"test-ci--src/orders/orders.service.spec.ts"

This example verifies the Atomizer's generated tasks, runs the split CI target only for affected projects with a bounded parallelism, and reruns one per-file child target locally to reproduce a CI-only failure.

Managing reports, flaky tests and an incremental rollout

When one suite becomes forty tasks, each task produces its own coverage file and JUnit XML. If every task writes to the same path, a cache restore for one file can overwrite the report from another. Have the test runner derive the report filename from the spec file it is running, so each task writes to its own location, for example reports/<project>/junit/<spec-name>.xml. Then declare the parent report directory in the target's outputs, for example {workspaceRoot}/reports/{projectName}, so a cache hit restores the files that task originally produced. Once all tasks finish, add a separate CI step that merges the coverage files and collects the JUnit files. This step should not depend on any one task having run fresh, because cached tasks still restore their reports.

The task graph also gets much larger. A failure that used to show up as one long log now shows up as one short log among dozens. Make your CI summary list failing task names. Otherwise engineers end up scrolling through results for files that passed.

Per-file retries lower the cost of a flaky test, because you rerun one file instead of the whole suite. They can also hide flakiness, since a test that fails and then passes on retry still shows as green. Record each retried file, either in the pipeline or from the JUnit output, and review that list regularly.

Roll out to one slow project first. Using your own pipeline metrics, compare wall-clock duration and cache hit rates from before and after the change across a few weeks of normal commits. Expand to more projects only if the numbers improve. This keeps the migration effort proportional to the time it actually saves.

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 atomizer, 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.