Subtopic · Root Causes of JavaScript Test Flakiness

Race Conditions in Parallel Test Runs: Detection & Resolution

Parallel execution collapses a thirty-minute suite into five, but it trades wall-clock time for concurrency risk: the moment two workers share a database, a port, a file, or an implicit global, their timing overlaps and intermittent failures appear that never reproduce in isolation. These collisions are one of the core Root Causes of JavaScript Test Flakiness, and this guide covers how to spot shared state, isolate it per framework, orchestrate CI shards, and prove the fix with reliability metrics — so you keep the speed of parallelism without the flakiness.

15 sections 3 child guides URL: /root-causes-of-javascript-test-flakiness/race-conditions-in-parallel-test-runs/
Shared state versus isolated workers Top: three workers contending over one shared database collide. Bottom: each worker bound to a per-shard schema and its own browser context runs without contamination. Shared state — contention worker 1 worker 2 worker 3 one shared DB Isolated — per-shard state worker 1 worker 2 worker 3 schema_1 schema_2 schema_3 Per-shard schemas and per-worker contexts remove the contention that makes shared-state suites flaky.
The same three workers go from colliding on one database to running clean once each owns its own schema and context.

Identifying Shared State & Concurrency Conflicts #

Race conditions surface when a test assumes exclusive access to a resource that another worker is also touching: a database table, localStorage, a global singleton, a fixed port, or a temp file. Because CI schedulers distribute specs non-deterministically, the offending pair changes run to run, which is why the failure “moves around” and why re-running clears it. In modern frontends the problem compounds — unresolved promises from Async State Management in E2E Tests and mid-flight DOM Mutation & Rendering Races widen the window in which two workers can interleave.

Shared-state audit surfaces Five common shared surfaces — database, storage, globals, ports, and temp files — that leak between parallel workers. shared surface? database rows localStorage global singletons fixed ports temp files
Audit each surface before enabling parallelism; every unowned resource is a future flake.

Strict isolation adds per-spec overhead, but the right level is cheap: isolate at the browser-context and database-transaction layer rather than spinning up a fresh VM per test. That keeps contention at zero while adding only milliseconds.

Framework-Specific Isolation Patterns #

Playwright shards at the process level with strict browser-context isolation via fullyParallel: true; Cypress distributes specs across CI matrix jobs, each running its own process. For component tests you must explicitly mock the network and reset module state — the detailed patterns in Resolving Race Conditions in Cypress Component Tests show how to force deterministic rendering before assertions fire.

Three isolation vectors Isolate browser contexts, network mocks, and database state to give each worker private ownership. browser context private cookies, storage, session network mocks scoped per spec, no shared server database state schema prefix or txn rollback
Own all three vectors per worker and cross-worker contamination disappears.

The key vectors: private browser contexts per worker (no cookie or localStorage bleed), network interceptors scoped to a single spec, and database state guarded by transactional rollbacks or unique schema prefixes such as test_worker_${SHARD_INDEX}.

CI Pipeline Configuration & Worker Orchestration #

Effective parallelism needs a pipeline that allocates workers dynamically and splits suites by execution time, not file count. Matrix builds isolate database connections, mock servers, and browser instances per worker; idempotent setup and teardown run independently per shard.

CI matrix fan-out into isolated shards One pipeline fans four shards, each with its own database URL suffix and artifact directory. e2e pipeline shard 1/4db_shard_1 shard 2/4db_shard_2 shard 3/4db_shard_3 shard 4/4db_shard_4
Each shard receives a distinct database suffix and artifact path, so nothing is shared across the matrix.
# .github/workflows/ci.yml — one isolated worker per matrix shard
name: Parallel E2E Pipeline
on: [push, pull_request]
jobs:
  e2e-shards:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false   # every shard must report, or flaky metrics undercount
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'npm' }
      - run: npm ci
      - run: npx playwright install --with-deps
      - name: Run Parallel Tests
        run: npx playwright test --shard=${{ matrix.shard }}/4
        env:
          # per-shard DB suffix = zero cross-shard writes; cost is provisioning N DBs
          DATABASE_URL: ${{ secrets.DB_URL }}_shard_${{ matrix.shard }}
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: test-results-${{ matrix.shard }}
          path: test-results/

Split by execution time when you have historical duration data; fixed-count sharding is simpler but produces straggler workers. Always keep fail-fast: false so a single shard’s failure does not cancel the others and hide flakiness signal.

Step-by-Step Implementation Workflow #

Work the change in order — auditing shared state before enabling parallel mode prevents you from scaling the very collisions you are trying to remove.

Rollout order for parallel isolation Audit shared state, enable native parallel mode, replace fixed waits, isolate data, then track flakiness. 1 auditshared state 2 parallelisolation flags 3 kill fixedwaits 4 isolateDB per worker 5 trackflakiness
Order matters: isolate state before turning up the worker count, then measure.
  1. Audit shared state — scan for localStorage, global mocks, singleton fixtures, and shared ports.
  2. Enable native parallel mode with strict isolation (fullyParallel: true in Playwright, CI matrix for Cypress).
  3. Replace fixed waits — drop cy.wait(ms) and page.waitForTimeout() for network interception and DOM state assertions.
  4. Isolate data per worker — transaction rollbacks or unique schema prefixes; see isolating database state in parallel Jest workers and preventing sharded runner seed collisions.
  5. Integrate flakiness tracking so unstable specs auto-quarantine and merges block until fixed.

Production Configuration Examples #

The framework config is where isolation becomes real. Playwright’s fullyParallel plus a clean context per test, and Cypress’s default testIsolation, do most of the work.

Config knobs and their reliability effect fullyParallel, workers, retries, and testIsolation each map to a reliability or cost effect. fullyParallelmax isolation workers: Nspeed vs contention retries: 2signal not mask testIsolationclean state More workers = faster builds, higher collision odds until isolation is proven. Cap workers, then raise once per-worker isolation holds.
Each knob trades speed against interference; isolation flags let you turn workers up safely.
// playwright.config.ts — process-level parallelism with clean contexts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  fullyParallel: true,
  workers: process.env.CI ? 4 : 2,
  retries: process.env.CI ? 2 : 0, // retries reveal flakes; never treat a retry-pass as clean
  use: {
    // omit storageState so every test gets a fresh context — no shared auth/cookies
    trace: 'on-first-retry',
  },
  reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list',
});
// cypress.config.ts — testIsolation clears state between tests; task purges per-worker data
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    testIsolation: true, // default since Cypress 12: fresh state per test
    setupNodeEvents(on, config) {
      on('task', {
        async cleanupDB() {
          // per-worker rollback/purge — trade small latency for guaranteed isolation
          const workerId = config.env.WORKER_ID ?? 'default';
          console.log(`[task] cleanupDB for worker ${workerId}`);
          return null;
        },
      });
      return config;
    },
  },
});

Every Fixed Name Is a Collision Waiting to Happen #

Parallel execution turns a class of harmless-looking constants into contention points. Anything with a fixed name that two copies of a test can hold at once will eventually be held at once, and the resulting failure is intermittent because it depends on how the scheduler interleaves the workers.

The recurring offenders are consistent across codebases: a hard-coded port for a local server, a temporary file path, a database record with a fixed identifier, a user account shared by every test that needs to be signed in, a fixed key in an external cache, and a screenshot or download directory. Each works perfectly at one worker and starts failing somewhere between two and eight, which is exactly the transition most suites make once they grow.

The remedy is namespacing by worker, and the namespace should come from the runner rather than from a random value — a worker index is stable within a run, which keeps failures reproducible, while a random suffix makes them unrepeatable. Ports should be allocated rather than assigned: ask the operating system for a free one and pass it to the application under test.

// Derive every shared name from the worker index, not from a constant.
// Trade-off: more plumbing to pass identifiers around, and it is the difference
// between a suite that scales with workers and one that breaks at four.
const worker = process.env.TEST_WORKER_INDEX ?? '0';
const schema  = `test_w${worker}`;
const tmpDir  = `/tmp/suite-${worker}`;
const account = `qa+w${worker}@example.com`;

A useful audit is to grep the suite for literal ports, /tmp paths and email addresses. The list that comes back is, with few exceptions, the complete inventory of collisions the suite will hit as its worker count rises.

Contention Is Not the Same as Collision #

Two failure modes travel together under “parallel flakiness”, and they need opposite fixes.

A collision is two tests touching the same resource. It produces sharply attributable failures — a duplicate key error, a port already in use, an assertion seeing another test’s data — and it gets worse as workers increase. The fix is isolation: namespace the resource so no two tests can meet on it.

Contention is tests competing for the machine. It produces timeouts spread thinly across many unrelated tests, tracks the load on the runner rather than the test count, and gets worse as workers exceed available cores. The fix is fewer workers, not more isolation, because the tests are already independent — they simply have less compute than the waits assumed.

Distinguishing them takes one experiment: run the suite at half the worker count. Collisions are unaffected or slightly less likely; contention improves markedly. Skipping that experiment is why teams sometimes spend a sprint building elaborate isolation for a problem that was over-subscription, or add capacity for a problem that was a shared temporary file.

The signature in reporting is also different. Collisions concentrate in a few tests and produce varied error messages; contention spreads across dozens of tests and produces mostly timeout signatures. That breadth measure — distinct failing tests versus total failure events — is the fastest available discriminator, and it is discussed further in Correlating Flakiness with CI Runner Load.

Ordering Assumptions Hidden Inside a File #

Parallelism at the file level is what most teams enable first, and it leaves an assumption intact: that tests within a file run in order, so one can set up state the next consumes. That assumption is invisible until someone enables full parallelism, at which point tests inside a file run concurrently and the setup-then-consume pairs break.

The pattern is easy to recognise once named. A test titled “creates the record” followed by one titled “edits the record” is a sequence pretending to be two tests; it shares state through the system under test, and neither can run alone. The same shape appears with before hooks that seed data which individual tests then mutate, so the second test sees the first one’s edits.

Making these independent is usually a small change with a disproportionate payoff: each test creates the data it needs, with a unique key, and asserts on what it created. The suite then survives full parallelism, survives shuffling, and — the underrated benefit — each test can be run alone when it fails, which is the single most useful diagnostic available.

Where a genuine multi-step journey must be verified end to end, express it as one test with several steps rather than as several tests. A journey is one behaviour; splitting it into separate tests to make a report look granular creates an ordering dependency for no coverage gain.

Configuration Reference #

The options below cluster into two groups: isolation switches that reduce flakiness and throughput switches that trade speed against contention.

Isolation switches versus throughput switches fullyParallel, testIsolation, and per-shard DB reduce flakiness, while worker count and retries trade speed against contention. reduces flakiness fullyParallel testIsolation per-shard DATABASE_URL speed vs contention workers: N retries: 1–2 --shard k/n
Turn the green switches on first; tune the amber ones once isolation is proven.
Option Framework Values Default Effect on flakiness
fullyParallel Playwright true/false false true runs every test file in its own worker context — maximal isolation
workers Playwright integer / % logical cores More workers cut wall time but raise collision odds until state is isolated
retries Playwright/Cypress integer ≥ 0 0 Surfaces the flaky signal; never mask a regression as green
testIsolation Cypress true/false true Clears cookies/storage between tests, preventing session bleed
--shard Playwright k/n off Deterministic split; pair with per-shard DB suffix to avoid cross-writes
DATABASE_URL suffix CI string shared A per-shard suffix removes cross-shard writes entirely

Sharding, Seeds and Reproducibility #

Parallelism across machines adds a reproducibility problem that parallelism within a machine does not. A sharded suite distributes files across runners, so “the tests that ran before this one” depends on the shard assignment — and if that assignment is derived from timing data rather than from a stable rule, the same commit produces different groupings on different runs.

That matters the moment a failure needs reproducing. A shuffled run records a seed, but the seed alone does not determine which files share a worker; the worker count and the shard assignment do. Reproducing a CI failure locally therefore requires three things to match: the seed, the worker count, and the shard the failing test landed in. Teams that record only the seed spend a long time wondering why the failure will not reappear.

Duration-based sharding is worth the extra bookkeeping because unbalanced shards waste wall-clock time — one shard finishing in four minutes while another runs for fourteen — but it must persist its partition. Writing the assignment to an artifact makes both reproduction and selective retry possible; without it, a retry of “shard three” may run an entirely different set of tests than the one that failed.

# Reproduce a sharded failure: seed, worker count and shard must all match.
# Trade-off: recording the partition adds an artifact per run, and it is the
# difference between a reproducible failure and a mystery.
npx playwright test --shard=3/8 --workers=2 --grep-invert @quarantined

The related discipline is to print all three values as the first lines of the test job’s log. A failure whose reproduction parameters are visible in the log is a bug; one whose parameters were never recorded is a rumour.

Cross-Worker Effects That Look Like Test Bugs #

Some parallel failures originate outside the test process entirely, and chasing them inside the suite is wasted effort.

Shared services are the most common. Several workers hitting one application server can exhaust its connection pool, trip a rate limiter, or serialise behind a global lock — none of which is visible in the test code, and all of which produce timeouts that vary with worker count. The diagnostic is to watch the dependency rather than the tests: a connection pool at its limit or a 429 in the server log settles it immediately.

Shared external accounts produce a subtler version. Two workers signed in as the same user will interfere on the server no matter how well the browser state is isolated, because the isolation was applied to the wrong layer. Browser-side isolation cannot fix server-side sharing, which is why account-per-worker is a distinct requirement from context-per-test.

Disk and memory pressure affect everything at once. Browsers are memory-hungry, and several workers each running one can push a small runner into swapping or trigger the out-of-memory killer, which manifests as a whole shard dying rather than as a test failing. That signature — a shard disappearing rather than reporting failures — is worth recognising, because no per-test retry can rescue a process that no longer exists.

Each of these is fixed outside the spec file: more capacity, per-worker accounts, connection pool sizing, or fewer workers. Recognising them early avoids the common trap of rewriting perfectly good tests in response to an infrastructure limit, and the classification approach in Flaky Test Triage & Ownership exists precisely to route these to the team that can act on them.

Common Pitfalls #

Each pitfall below traces to an unowned resource; the fix is always to give the worker private state.

Parallel-run anti-patterns and fixes Four parallel-run anti-patterns each mapped to the isolation fix that removes them. assuming file order across workers make each spec order-independent sharing cookies / localStorage private context per worker no DB rollback between specs transactional rollback each test one shared mock server per-file interceptors / port isolation
Every red anti-pattern is an unowned resource; the green fix hands ownership to the worker.
  • Assuming file order guarantees execution sequence — schedulers distribute specs non-deterministically; make each independent.
  • Sharing localStorage, cookies, or IndexedDB across contexts — use a private context per worker.
  • Skipping database rollbacks between specs — wrap each test in a transaction and roll back.
  • Overusing fixed waits — replace cy.wait(ms)/page.waitForTimeout() with explicit assertions.
  • One shared mock API server without routing — scope interceptors per file or isolate ports per worker.

Reliability Metrics & KPIs #

Track the numbers that reveal contention before it becomes a red build.

Parallel-run reliability targets Targets for flake rate, shard timeout, retry success, and isolation score. flake rate< 2% shard timeout≤ 15 min retry success> 85% isolation score100% Export via JUnit XML / Playwright JSON and alert past the flake budget.
A compact KPI board for parallel suites, exported straight from the reporter.

Stream these into Historical Flakiness Tracking & Analytics and alert when the intermittent-failure rate crosses 2%. Route breaching specs into Building Auto-Quarantine Workflows so the suite stays green while the root cause is fixed.

FAQ #

How do I differentiate a true race condition from a network timeout? A race condition fails non-deterministically based on execution order or load; a timeout fails consistently after a fixed duration. Reproduce with simulated latency and varying worker counts — if the failure moves with worker count, it is a race.

Can I run Cypress and Playwright tests in parallel on the same runner? Yes, but isolate their browser instances, port allocations, and artifact directories. Containerize or use separate jobs per framework to avoid port conflicts and resource contention.

What retry strategy suits parallel suites? Limit retries to 1–2. More than that masks races. Combine retries with automatic quarantine and root-cause dashboards so a retry-pass still gets investigated.

Explore next

Child guides in this section