What the Nx Atomizer does and who it is for

The Atomizer is not a separate tool. It is behavior built into Nx inference plugins such as the Cypress and Playwright plugins that you register in the plugins array of nx.json. When the plugin is configured with a CI target name, it reads your e2e configuration file, finds the spec files that match its patterns, and creates one Nx target per spec file. It also creates an aggregate target, conventionally e2e-ci, that depends on all of the per-file targets. Your test code is not changed. The splitting happens only in the project graph Nx builds at runtime.

cypress.config.ts / playwright.config.ts
              │
              ▼
   plugin reads config, globs specs
              │
   ┌──────────┼──────────────┐
   ▼          ▼              ▼
login.cy.ts  cart.cy.ts   checkout.cy.ts
   │          │              │
   ▼          ▼              ▼
e2e-ci--…  e2e-ci--…     e2e-ci--…     (one target per file)
   └──────────┼──────────────┘
              ▼
           e2e-ci                       (depends on all of them)

Each spec becomes an independent task, which has several consequences:

  • Parallelism and distribution. Nx can schedule the specs across local workers or several CI machines. Without the split, the suite is a single task that has to run on one machine.
  • Finer-grained caching. Each file has its own cache entry, so one failing spec does not invalidate the results of files that passed.
  • Cheaper retries. You rerun the one flaky file, not the whole 40-minute suite.

The Atomizer helps most when your team already pays for parallel capacity it can't use well. A typical example is platform or backend engineers who own a slow, serial e2e suite in an Nx monorepo and already have multiple CI runners or spare cores available.

┌──────────────────────────────────────────────────────────────────────────┐
│ nx.json plugins: Cypress / Playwright plugin                             │
│ option ciTargetName: "e2e-ci"                                            │
└────────────────────────────────────┬─────────────────────────────────────┘
                                     ▼
┌──────────────────────────────────────────────────────────────────────────┐
│ reads cypress.config.ts / playwright.config.ts                           │
│ globs spec files matching the plugin's patterns                          │
└────────────────────────────────────┬─────────────────────────────────────┘
           ┌─────────────────────────┼─────────────────────────┐
           ▼                         ▼                         ▼
┌──────────────────────┐  ┌──────────────────────┐  ┌──────────────────────┐
│ login.cy.ts          │  │ cart.cy.ts           │  │ checkout.cy.ts       │
└──────────┬───────────┘  └──────────┬───────────┘  └──────────┬───────────┘
           ▼                         ▼                         ▼
┌──────────────────────┐  ┌──────────────────────┐  ┌──────────────────────┐
│ Nx target            │  │ Nx target            │  │ Nx target            │
│ e2e-ci--<path>/      │  │ e2e-ci--<path>/      │  │ e2e-ci--<path>/      │
│   login.cy.ts        │  │   cart.cy.ts         │  │   checkout.cy.ts     │
└──────────┬───────────┘  └──────────┬───────────┘  └──────────┬───────────┘
           └─────────────────────────┼─────────────────────────┘
                                     ▼
                   ┌────────────────────────────────────┐
                   │ e2e-ci (aggregate CI target)       │
                   │ dependsOn: all per-file targets    │
                   └────────────────────────────────────┘

When the Cypress or Playwright plugin in nx.json has a CI target name set, it reads the e2e config, finds the spec files, adds one cacheable Nx target per spec, and adds an aggregate e2e-ci target that depends on all of them, without changing any test code.

Enabling the Atomizer and inspecting the generated targets

Atomization comes from the inferred-task plugin for your e2e runner, so the setup is mostly configuration. In nx.json, register the plugin under plugins. For example, use @nx/playwright/plugin or @nx/cypress/plugin, depending on your runner. Give it a ciTargetName option. The value conventionally used is e2e-ci. Without that option, the plugin infers the normal e2e target but does not generate per-file targets, so this setting turns atomization on.

Verify before you change the pipeline

Run nx show project <name> for an e2e project, or add --web to browse the result. You should see one target per spec file, named after the CI target and the file path. You should also see an aggregate e2e-ci target whose dependsOn lists those per-file targets. Check three things:

  • Every spec you expect appears as its own target.
  • No helper or fixture files were picked up as specs.
  • The aggregate target depends on all of the per-file targets.

Treat this output as the source of truth. Inferred target names, defaults and option names depend on plugin options and your Nx version. Recheck after every Nx upgrade instead of assuming last quarter's shape still holds.

Why two targets

In CI, call nx affected -t e2e-ci. The aggregate target fans out into per-file tasks. Nx can distribute and cache those tasks individually, so a change touching one area reruns only the specs whose inputs changed. Leave the original e2e target in place for local work. It runs the suite as a single process, which matches how the runner behaves on its own and keeps interactive modes and debuggers working as developers expect.

# Assumes nx.json already registers the runner plugin with a CI target name, e.g.:
#   "plugins": [
#     { "plugin": "@nx/playwright/plugin",
#       "options": { "targetName": "e2e", "ciTargetName": "e2e-ci" } }
#   ]
# Replace myapp-e2e with your e2e project's name.

# Inspect what the plugin actually inferred. Your Nx version and plugin options
# decide target names, so read them here instead of assuming them.
nx show project myapp-e2e

# List every target name. Expect the plain e2e target, the aggregate e2e-ci
# target, and one e2e-ci--<spec path> target per spec file. Make sure no
# helper or fixture files show up as specs.
nx show project myapp-e2e --json | jq -r '.targets | keys[]'

# Confirm the aggregate target depends on all of the per-file targets.
nx show project myapp-e2e --json | jq '.targets["e2e-ci"].dependsOn'

# Optional: browse the same information in the project graph UI.
nx show project myapp-e2e --web

# CI: run the atomized target so each spec file is cached and distributed separately.
nx affected -t e2e-ci

# Local work and debugging: the original target still runs the suite as one process.
nx run myapp-e2e:e2e

Shell commands to verify the per-file targets and the aggregate e2e-ci dependsOn that the e2e plugin inferred, then run e2e-ci in CI while keeping the plain e2e target for local runs.

How per-file caching works: inputs, namedInputs and dependsOn

Each atomized target is an ordinary Nx task. Its cache key is a hash of the inputs configured for that target, together with environment and runtime inputs. The spec file is what makes one file task's hash different from another's. Most of the hash, though, is shared: the e2e project's config and support files, plus whatever the app contributes through inputs such as ^production or the build's output files.

The shared part is why one edit to the app invalidates every file task at once. If the app's sources or build output appear in the inputs, all file tasks depend on them equally, so all of them get new hashes. To find out what is actually feeding a task, inspect its resolved inputs in the project graph. Don't guess from the config. Then tighten namedInputs to exclude files that cannot change runtime behaviour, such as unit tests, stories, docs, and lint config.

Don't go further than that. A true end-to-end test exercises the whole app, so broad invalidation after an app change is usually the correct result. If you drop the app from the inputs to get more cache hits, you get false cache hits: a stale pass gets reported for code that was never tested. That is worse than a slow suite.

Express the app build, and any server or fixture setup, as dependsOn on each file task. Every file task should be able to run on a fresh agent with nothing left behind by earlier tasks. Remote execution and retries assume this. Ordering by accident does not count as a dependency.

{
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "sharedGlobals": [],
    "production": [
      "default",
      "!{projectRoot}/**/*.spec.ts",
      "!{projectRoot}/**/*.stories.tsx",
      "!{projectRoot}/**/*.md",
      "!{projectRoot}/.eslintrc.json",
      "!{projectRoot}/eslint.config.mjs"
    ]
  },
  "targetDefaults": {
    "build": {
      "inputs": ["production", "^production"],
      "cache": true
    },
    "e2e-ci--**/*": {
      "inputs": ["default", "^production"],
      "dependsOn": ["^build", "seed-fixtures"],
      "cache": true
    }
  }
}

An nx.json sketch where every atomized e2e file task keeps the app's production files as inputs, excludes only files that cannot change runtime behaviour, and declares the app build and fixture seeding as dependsOn.

Preparing specs to run in isolation and in any order

Most suites that pass serially carry hidden coupling, and splitting by file exposes it as flakiness. Look for these patterns:

  • Shared database state. One spec deletes rows another spec expects.
  • Seeded-data assumptions. A spec relies on a user or record that an earlier spec created.
  • Ordering dependencies. checkout only passes because cart ran first.
  • Global login sessions. A single cached session is reused or invalidated across files.
  • Fixed ports. Two tasks on one agent both try to bind the same port.
  • Rate-limited third-party sandboxes. Concurrent tasks hit a payment or email sandbox and get throttled.

The fixes all give each file its own world. Create the data a file needs in its own setup hook and do not rely on a global seed. Give each file a unique tenant, org or user prefix so parallel runs cannot collide, and so cleanup can target only that file's data. Derive ports from an environment variable or let the OS assign a free one instead of hardcoding them. For shared external resources, put calls behind a stub where you can. Where the real sandbox is required, limit how many tasks reach it at once.

To find offenders, run each generated file target alone on a clean environment. A file that fails there depends on an earlier spec. Then run the file targets in shuffled parallel batches several times. A file that fails only in some orderings shares state with another file.

Plan for this cleanup to come before the rollout. If you enable per-file tasks on an unprepared suite, you get a fast pipeline that people stop trusting.

#!/usr/bin/env bash
# Find specs that are not isolated. Assumes an Nx project "shop-e2e" whose
# Atomizer targets are named "e2e-ci--<spec path>". reset-test-env.sh is a
# placeholder for your own script that wipes and re-creates the test DB.
set -uo pipefail
PROJECT=shop-e2e
BATCH=4
ROUNDS=5

# List the per-file targets Nx generated (skip the parent "e2e-ci" target).
mapfile -t TARGETS < <(npx nx show project "$PROJECT" --json \
  | jq -r '.targets | keys[] | select(startswith("e2e-ci--"))')

# Pass 1: each file alone on a clean environment, cache disabled.
# A failure here means the file depends on data or state from another spec.
for t in "${TARGETS[@]}"; do
  ./scripts/reset-test-env.sh
  npx nx run "$PROJECT:$t" --skip-nx-cache >/dev/null 2>&1 \
    || echo "FAILS ALONE: $t"
done

# Pass 2: shuffle files into random parallel batches, several rounds.
# A file that fails only in some groupings shares state with a neighbor.
for round in $(seq 1 "$ROUNDS"); do
  ./scripts/reset-test-env.sh
  mapfile -t SHUFFLED < <(printf '%s\n' "${TARGETS[@]}" | shuf)
  for ((i = 0; i < ${#SHUFFLED[@]}; i += BATCH)); do
    group=$(IFS=,; echo "${SHUFFLED[*]:i:BATCH}")
    npx nx run-many --projects="$PROJECT" --targets="$group" \
      --parallel="$BATCH" --skip-nx-cache >/dev/null 2>&1 \
      || echo "round $round FAILED in batch: $group"
  done
done

A shell script that runs each Atomizer-generated file target alone on a reset environment, then runs the targets in shuffled parallel batches over several rounds, so you can spot specs that depend on other specs or share state with them.

Controlling startup cost, outputs and reporting in CI

Splitting a suite into per-file tasks multiplies fixed costs. If every task boots a dev server with file watching and on-the-fly compilation, then launches a browser, that startup cost is paid once per file instead of once per suite. Serve a prebuilt production or static build instead. A static file server starts almost instantly and behaves the same way your users' environment does. Nx caches the build, and the atomized tasks depend on it. Spec files that contain only one or two quick tests rarely justify their own startup cost, so merge them into a neighbouring spec that covers the same feature.

Approach Where tasks run Good for Limit
--parallel on one runner One machine, several processes Small suites with enough CPU and memory Browsers compete for CPU and memory. Adding processes stops helping.
Distributed execution Several agents Large suites Agents need a shared remote cache, or each one rebuilds artifacts

A local cache disappears when the CI machine is torn down. Retries and re-runs only skip passing files when results live in a remote cache that every agent and every pipeline attempt can read.

Give each task its own output directory, keyed by project and spec file name, and declare it in the target's outputs. This stops parallel tasks from overwriting each other's screenshots, videos, traces and JUnit XML. It also lets cache hits restore those artifacts. Add a final step that collects every per-file JUnit file into one consolidated report. Name each test suite in that report after its spec path, so a failed task leads straight to the file you need to fix.

Impact on cost and engineering time: an illustrative estimate

Illustrative estimate (assumed numbers, not a benchmark). Assume 60 spec files averaging 2 minutes each, 1 minute of per-task startup (install check, app build or serve, browser launch), 8 parallel workers, and a 70% cache-hit rate on PRs that touch only unrelated projects.

  • Total compute ≈ N × (1 − h) × (s + d)
  • Wall-clock ≈ ⌈N × (1 − h) / W⌉ × (s + d)

where N is the number of spec files, h is the cache-hit rate, s is the per-task startup time, d is the average file duration, and W is the number of workers. The monolithic suite pays startup once: s + N × d.

Scenario Tasks run Total compute Wall-clock
Single e2e target 1 121 min 121 min
Atomized, feature PR (h = 0) 60 180 min 24 min
Atomized, unrelated PR (h = 0.7) 18 54 min 9 min
Atomized, retry one flaky file 1 3 min 3 min

Wall-clock drops sharply, but compute rises because startup is paid per task. If your CI bills by machine-minute, the bill can grow even as pipelines finish faster.

Most of the engineering time is saved in three places. Retries re-run one file instead of the whole suite. PRs that touch only unrelated projects mostly hit the cache. Re-runs after an infrastructure blip reuse every file that already passed. A typical feature PR that changes the app under test invalidates every file's inputs, so it gets parallelism but no caching.

Weigh these gains against the one-time work of making specs order-independent. Also count the ongoing cost of a task graph with dozens of extra nodes, which makes run logs and failure triage noisier.

When to avoid the Atomizer, and the alternatives

Splitting pays off only when the extra tasks can actually run at the same time. Skip it in these cases:

  • No parallel workers or distribution. If CI runs tasks one after another on a single agent, per-file tasks run in sequence. You pay every startup cost and gain only finer cache hits.
  • A single small runner. With two or three cores, several browser instances and app servers compete for CPU and memory. The split run can finish later than the original target.
  • Many tiny specs. When a spec runs for a few seconds but booting the app and browser takes longer, most of each task is setup.
  • Heavy shared-resource contention. Specs that use one database, a seeded account or a rate-limited sandbox API will block each other or become flaky when run concurrently.

The alternatives trade cache granularity against effort and cost differently:

Option Cache granularity Setup effort Compute cost Debugging complexity
Keep single e2e target Whole suite None One startup, no parallel gain Low: one log
Runner's own sharding or parallelism Whole suite (Nx cache sees one target) Low: runner config Shared startup per shard Medium: per-shard reports
A few coarse projects by feature area Per feature area Medium: move specs and fixtures A handful of startups Low to medium
Narrow scope with nx affected Per project, skipped when unaffected Low if project boundaries are accurate Lowest when changes are localized Low

These options combine. Coarse feature projects together with nx affected often capture most of the savings without the Atomizer's many task nodes. Runner sharding inside each project then shortens the projects that still 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 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.