What Nx Atomizer is and why a single e2e target blocks parallelism
Nx treats a target as the smallest unit of work. It schedules, caches, and distributes whole tasks, and it never looks inside one. If your e2e target runs every Playwright or Cypress spec in a single process, Nx sees one task. That task runs on one CI agent no matter how many agents you provision. It produces one cache entry, so a change that affects a single spec invalidates the whole suite. A single flaky test also forces a rerun of everything. Adding agents doesn't shorten the critical path, because there is nothing for Nx to hand out.
Atomizer splits that task. It is part of the inferred-task ("Project Crystal") plugins for Playwright and Cypress. The plugin scans the project's spec files and infers one CI task per file. It also adds a parent CI target, conventionally e2e-ci, that depends on all of them. Each per-file task is cached separately and can be scheduled on any agent. The parent task succeeds only when every child succeeds. Your local e2e target stays unchanged, so developers still run the suite the way they always have.
e2e-ci (parent, no work of its own)
┌────────────┬────────┴───┬────────────┐
e2e-ci--login e2e-ci--cart e2e-ci--search e2e-ci--checkout
│ │ │ │
Agent 1 Agent 2 Agent 3 Agent 1
└────────────┴─────┬──────┴────────────┘
app:build
(shared dependency, cached once,
restored on each agent)
The build runs once and is reused from the cache. Only the spec execution fans out across agents.
┌──────────────────────────┐ ┌──────────────────────────────────┐
│ e2e (local, unchanged) │ │ e2e-ci (CI parent, no own work) │
│ every spec, one process, │ │ succeeds only when every child │
│ one agent, one cache key │ │ task succeeds │
└──────────────────────────┘ └────────────────┬─────────────────┘
│ dependsOn
┌──────────────────┬──────────────────┬──────┴───────────┐
▼ ▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ e2e-ci--login │ │ e2e-ci--cart │ │ e2e-ci--search │ │e2e-ci--checkout│
│ login.spec.ts │ │ cart.spec.ts │ │ search.spec.ts │ │checkout.spec.ts│
│ own cache entry│ │ own cache entry│ │ own cache entry│ │ own cache entry│
│ runs on Agent 1│ │ runs on Agent 2│ │ runs on Agent 3│ │ runs on Agent 1│
└───────┬────────┘ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘
│ │ │ │ dependsOn
└──────────────────┴─────────┬────────┴──────────────────┘
▼
┌──────────────────────────────────────┐
│ app:build (shared dependency) │
│ runs once; output cached and │
│ restored on every agent │
└──────────────────────────────────────┘
Atomizer leaves the local e2e target untouched and infers a CI parent target, e2e-ci, that depends on one task per spec file; Nx caches each task separately, distributes them across agents, and reuses a single cached app:build on every agent.
Who should use it and when it pays off
Atomizer pays off for a specific kind of team: one whose e2e suite has become the longest step in CI, whose spec files can run in any order, and who already run distributed task execution or are willing to set it up. Splitting a target into per-file tasks does nothing on its own. The speedup comes from handing those tasks to several agents at once.
Three conditions decide whether the split is worth it:
- Independent spec files. Each file must set up and tear down its own state. If
checkout.spec.tsrelies on a user created insignup.spec.ts, running them on different agents breaks that dependency. - Reasonable balance. One twelve-minute file next to twenty thirty-second files leaves the wall-clock time set by the large file, however many agents you add.
- Startup cost small relative to file runtime. Every atomized task pays for browser launch, web server boot, and cache restore. When a file runs for a few seconds, that overhead can cost more than the parallelism saves.
| Situation | Atomize? | Why |
|---|---|---|
| Suite takes many minutes, files self-contained | Yes | Spec execution spreads cleanly across agents |
| Suite finishes in a minute or two | No | Per-task overhead cancels the gain |
| No distributed execution, single CI runner | No | Tasks still run on one machine |
| Specs share state or depend on run order | Not yet | Fix isolation first or results become flaky |
| Shared staging environment with limited capacity | Caution | Parallel agents can overload it or collide on data |
Auditing your suite for test isolation before atomizing
Atomizer turns each spec file into its own task, and Nx can schedule those tasks on any agent in any order. Hidden coupling that a single sequential run tolerated will surface as intermittent failures. Audit for it before you change any configuration.
The core rule is that every spec file must be able to run alone, first, on a fresh machine, and pass. Violations usually come from these sources:
- Execution order. A file that expects records created by an earlier file breaks when it lands on a different agent.
- Global setup side effects. If a global setup step seeds data or writes files that specs read, check that every agent runs it and that it is safe to run many times at once.
- Shared login state. Saving an authenticated session to disk once and reusing it across files only works if every agent produces that file itself. One agent's session file does not exist on another agent's disk.
- Leftover data. Assertions such as "the list has three items" fail as soon as another agent's file writes to the same tables.
Backend contention
Many agents hitting one staging backend can trip rate limits, exhaust connection pools, or collide on unique fields like email addresses. Give each test its own data, for example by adding a per-run or per-worker suffix to identifiers. Also confirm that the backend can handle your planned agent count.
Checklist
- Run each spec file individually and confirm it passes.
- Run the files in reverse or shuffled order.
- Run each file several times in a row to expose leftover state.
- Search for authentication or data artifacts written in one file and read in another.
- Replace hardcoded unique values with generated ones.
- Load-test the shared backend at your planned level of parallelism.
How to enable Atomizer with the Playwright Nx plugin
Atomization is configured through the Playwright plugin's entry in the plugins array of nx.json. The plugin infers e2e targets from each project's Playwright config. Set a CI target name option, such as ciTargetName, to tell it to also create a parent CI target plus one child task per spec file. The parent target fans out to the children, so CI invokes one name and Nx distributes the per-file work.
Don't assume what the children are called. Run nx show project <name> and read the generated targets. The output shows each task's name, the command it runs, its inputs and its dependencies. Treat that output as the source of truth.
Next, review the inputs. If a child task's inputs omit the application source, a change to the app can produce a cache hit that replays an old passing result. The same applies to the Playwright config and to environment variables like the base URL or feature flags. Declare all three so that the cache key reflects what the test actually exercised.
Avoid having every child task build the app. Add a dependsOn on the app's build target so the build runs once, its outputs are cached, and agents restore them instead of recompiling. If you test against a deployed environment, drop the build dependency and pass the target URL as a declared env input.
Inferred targets are generated by plugin code, so their names and shape can change when you upgrade Nx. Re-run nx show project after each upgrade. In CI scripts, reference only the parent CI target and never list per-file target names.
# 1) Register the Playwright plugin. ciTargetName adds a parent CI target
# plus one child task per spec file. targetDefaults uses a glob so the
# settings apply to whatever child names the plugin generates.
cat > nx.json <<'EOF'
{
"plugins": [
{
"plugin": "@nx/playwright/plugin",
"options": {
"targetName": "e2e",
"ciTargetName": "e2e-ci"
}
}
],
"targetDefaults": {
"e2e-ci--**/*": {
"dependsOn": ["^build"],
"inputs": ["default", "^production", { "env": "BASE_URL" }]
}
}
}
EOF
# "default" covers the e2e project's specs and playwright.config.ts.
# "^production" covers the app's source, so app changes invalidate the cache.
# The BASE_URL env input changes the cache key when the target environment changes.
# "^build" builds the app once and caches it, so children restore the output
# instead of rebuilding. Drop it when testing a deployed environment.
# 2) Inspect the generated tasks instead of guessing their names.
# Re-run this after every Nx upgrade.
npx nx show project myapp-e2e
# 3) In CI, call only the parent target. Nx fans out to the per-file children.
BASE_URL= npx nx run myapp-e2e:e2e-ci
Registers the Playwright plugin with a CI target name and sets glob-based target defaults that declare app-source, config and env inputs plus a build dependency. It then inspects the generated per-file tasks and runs only the parent CI target.
Running atomized e2e tasks in CI
With per-file targets in place, the CI change is small. Replace the old e2e invocation with the parent target, for example nx affected -t e2e-ci. Nx resolves that target's dependencies to the generated per-file tasks, so the pipeline definition stays the same when spec files are added, renamed, or deleted.
On a single machine this only adds parallelism up to the local core count. The real gain comes from distributed task execution. Nx's orchestrator hands individual spec-file tasks to whichever agent is free, so a suite that used to run as one long job spreads across the agent pool. The longest single spec file then sets the floor on wall-clock time, not the full suite.
Caching and retries at file granularity
Each spec file is a separately cached task. If a change affects the project but leaves a given spec's inputs untouched, that spec can be restored from cache instead of rerun. Retries follow the same boundary. When your CI setup retries failed tasks, a flaky failure costs one file's runtime, not the whole suite. Because each file appears as its own task in CI output, a file that keeps failing and then passing on retry is easy to spot and assign to an owner.
Reassembling the report
The trade-off is fragmented output. Results, traces, and videos now sit in many task output directories on many agents. Configure Playwright's blob reporter so each task writes a mergeable result file. Collect those files as CI artifacts from every agent, then run playwright merge-reports in a final step to produce one HTML report with traces attached.
#!/usr/bin/env bash
# CI script for the main agent (orchestrator). Assumes the Playwright config uses
# the blob reporter, e.g. reporter: [['blob', { outputDir: 'blob-report' }]],
# so every per-file task writes a mergeable result file instead of an HTML report.
set -euo pipefail
# 1. Enable distributed task execution. Nx hands individual tasks, including each
# atomized spec-file task, to whichever of the 5 agents is free.
# --stop-agents-after shuts agents down once the last e2e-ci task finishes.
npx nx-cloud start-ci-run \
--distribute-on="5 linux-medium-js" \
--stop-agents-after="e2e-ci"
# 2. Run the parent target, not the old monolithic e2e target. Nx expands e2e-ci
# into one task per spec file. Unchanged specs can be restored from cache,
# and each file shows up as its own task in the CI output.
# Let the run fail (|| status=$?) so the report is still merged afterwards.
status=0
npx nx affected -t lint test build e2e-ci --base=origin/main || status=$?
# 3. Gather blob files into one directory. With distribution, task outputs are
# brought back to the main agent; if your setup instead uploads them as CI
# artifacts per agent, download them here. Blob files need unique names so
# they do not overwrite each other when copied into one directory.
mkdir -p all-blob-reports
find . -path ./node_modules -prune -o -path ./all-blob-reports -prune \
-o -name '*.zip' -path '*blob-report*' -print \
| xargs -r -I{} cp {} all-blob-reports/
# 4. Merge every per-file result into a single HTML report with traces attached.
npx playwright merge-reports --reporter html ./all-blob-reports
# Publish playwright-report/ as a CI artifact, then propagate the test result.
exit "$status"
A main-agent CI script that distributes atomized e2e-ci tasks across agents with Nx, still merges reports when tests fail, and combines each spec file's Playwright blob output into one HTML report.
Balancing spec files and the cost of per-task overhead
The slowest spec file sets your minimum wall-clock time. Atomizer splits by file, not by individual test, so a single 9-minute spec still occupies one agent for 9 minutes no matter how many agents you add. To find these files, sort per-task durations from the CI run, or from the merged report, and split the outliers by hand along feature boundaries.
Splitting too far also costs time. Every atomized task pays its own setup: browser launch, web server start, and fixture seeding. Every agent also pays for its own startup and dependency install.
Illustrative estimate (assumed numbers, not a benchmark). The assumed inputs are:
- 40 minutes of total test time spread across 40 uneven files
- A longest file of 9 minutes
- 8 agents
- 0.75 minutes of overhead per task
- 3 minutes of startup per agent
The formulas are:
- Wall-clock ≈ max(longest + task overhead, (total + files × task overhead) ÷ agents) + agent startup
- Compute ≈ total + files × task overhead + agents × agent startup
| Setup | Wall-clock | Agent-minutes |
|---|---|---|
| One agent, one task | 40 + 3 = 43 min | 43 |
| 8 agents, 40 tasks | max(9.75, 70 ÷ 8 = 8.75) + 3 ≈ 12.75 min | 70 + 24 = 94 |
In this example, wall-clock time drops by roughly 70%, but compute more than doubles. The longest file, not the agent count, sets the floor.
Whether that trade is worth it depends on cost. Agent-minutes are cheap compared with engineers waiting on a pull request. Still, check whether reaching the floor requires more agents than the per-task overhead justifies.
Local workflow and alternatives to Atomizer
Locally, keep running the single e2e target. The atomized per-file tasks are useful when a distribution layer schedules them across agents. On a laptop they add per-task startup cost and give developers an unfamiliar set of task names to learn. Playwright already parallelises within one machine through workers, so nx e2e my-app-e2e with a sensible worker count stays the fastest local loop. Developers can still pass Playwright's own filters, such as a file path or --grep, to narrow a run. CI and local runs share one Playwright config, so behaviour does not diverge.
Atomizer is not the only way to cut CI wall-clock time. The options trade setup effort against how finely you can cache, retry and report:
| Approach | Setup effort | Caching granularity | Retry granularity | Required infrastructure | Reporting complexity |
|---|---|---|---|---|---|
| Playwright workers, one machine | Minimal | Whole suite | Individual test (Playwright retries) | One runner | Single report |
Native Playwright sharding (--shard) |
Low: a CI matrix | Per shard, if cached at all | Whole shard job | N CI jobs | Shard reports must be merged |
| Manually split e2e projects | Medium; ongoing upkeep | Per project | Per project | N CI jobs or Nx parallelism | One report per project |
| Atomizer with distributed execution | Medium, once | Per spec file | Per spec file | Distributed task execution agents | Per-task outputs, aggregated by Nx |
Native sharding is the right step if you do not run distributed execution. Its shards are fixed slices, so a single flaky spec forces the whole shard to rerun, and shard membership shifts whenever files are added. Manually split projects give stable boundaries, but someone has to keep rebalancing them as the suite grows.
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 e2e, 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.