Article · Root Causes of JavaScript Test Flakiness

Preventing Sharded Runner Seed Collisions

When you split a suite across shards, every runner that starts from the same random seed produces the same faker values, the same fixture IDs, and the same database keys — and the moment two shards write those identical rows concurrently, you get intermittent failures that look like async bugs but are really data collisions. This is one of the sharper edges of Race Conditions in Parallel Test Runs, and the fix is to derive a deterministic-but-distinct seed per shard from the shard index so each runner owns a private slice of the data space.

10 sections URL: /root-causes-of-javascript-test-flakiness/race-conditions-in-parallel-test-runs/preventing-sharded-runner-seed-collisions/
Shared seed collision versus per-shard derived seed Two shards with one shared seed collide on identical records; the same two shards with seeds derived from the shard index write disjoint records. Shared seed = 42 (collision) Shard 1 seed 42 user_001, user_002 Shard 2 seed 42 user_001, user_002 DB unique key duplicate -> fail Seed = base + shard index Shard 1 seed 1042 user_101, user_102 Shard 2 seed 2042 user_201, user_202 DB key disjoint OK Each shard owns a private namespace, so concurrent writes never overlap.
A single shared seed forces every shard to generate identical records; deriving the seed from the shard index gives each runner a disjoint data range.

Root cause #

Test data libraries are deterministic by design. faker.seed(42) guarantees the same sequence of names, emails, and IDs on every run so that snapshots stay stable. That determinism is a feature inside one process and a liability across many. When Playwright runs with --shard=1/4 and --shard=2/4 on separate GitHub Actions matrix jobs, both jobs import the same setupSeed() helper, both call faker.seed(42), and both generate [email protected]. If those jobs target a shared database, the second INSERT hits a unique constraint and the test that “sometimes fails” is actually losing a write race.

The collision is not limited to faker. Any source of supposedly-unique values that is keyed off a fixed seed will repeat across shards: crypto-free UUID stubs, sequential counter fixtures, hard-coded tenant slugs, or a shared S3 prefix. The reason it presents as flakiness rather than a hard failure is timing — when shards happen to write at slightly different moments, one wins and the suite passes; under load they interleave and one loses. Fixing it means giving every shard a seed that is reproducible (so a failure can be replayed) yet unique (so namespaces never overlap), and the shard index is the natural entropy source.

Step-by-step fix #

1. Expose the shard index to the test process #

GitHub Actions matrix jobs already know their shard number. Pass it through as an environment variable so the test code can read it.

# .github/workflows/e2e.yml — fan out into 4 shards, each with its own index
jobs:
  e2e:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      # SHARD_INDEX is the per-job entropy source for the seed; fail-fast:false
      # keeps every shard reporting so a collision is visible, not hidden.
      - run: npx playwright test --shard=${{ matrix.shard }}/4
        env:
          SHARD_INDEX: ${{ matrix.shard }}
          SHARD_TOTAL: "4"

2. Derive a per-shard seed instead of a constant #

Read the index once at setup and fold it into the base seed. Use the same helper for faker and for any other deterministic generator.

// tests/support/seed.ts — single source of per-shard determinism
import { faker } from '@faker-js/faker';

const BASE_SEED = 1000;

export function shardSeed(): number {
  // Falls back to worker id locally where SHARD_INDEX is unset; the offset
  // keeps shard ranges far apart so generated counters never overlap.
  const shard = Number(process.env.SHARD_INDEX ?? 1);
  return BASE_SEED + shard * 1_000_000;
}

export function setupSeed(): void {
  faker.seed(shardSeed()); // reproducible per shard, distinct across shards
}

3. Namespace fixtures and DB keys by shard, not just the RNG #

Seeding the RNG fixes generated values, but hard-coded identifiers still collide. Prefix every shared resource with the shard index so concurrent writes land in separate ranges — the same principle that keeps Cypress component test race conditions from corrupting shared module state.

// tests/support/namespace.ts — disjoint identifiers per shard
const shard = Number(process.env.SHARD_INDEX ?? 1);

export function scopedEmail(local: string): string {
  // s1_alice@... vs s2_alice@... — unique constraint can never collide.
  return `s${shard}_${local}@example.com`;
}

export function scopedTenant(): string {
  return `tenant-shard-${shard}`; // each shard owns one tenant row
}

4. Print the resolved seed so failures are replayable #

A derived seed is only useful if you can reproduce it. Log it at startup and on failure.

// tests/support/global-setup.ts
import { shardSeed } from './seed';

export default function globalSetup() {
  // Echo the seed so a CI failure on shard 3 can be replayed locally with
  // SHARD_INDEX=3 — determinism is worthless if you can't reconstruct it.
  console.log(`[seed] shard=${process.env.SHARD_INDEX ?? 'local'} seed=${shardSeed()}`);
}

The same approach maps onto Jest: --shard=1/4 exposes the split, and reading SHARD_INDEX (or JEST_WORKER_ID within a shard) into the seed gives you the same disjoint ranges. For worker-level database isolation underneath the shard, pair this with isolating database state in parallel Jest workers.

Pitfalls #

  • Forgetting the local fallback. If SHARD_INDEX is unset locally and you call Number(undefined), the seed becomes NaN and faker silently reverts to its default — re-introducing collisions. Mitigation: default to 1 (or JEST_WORKER_ID) explicitly.
  • Small offsets that overlap. Adding only +shard to the base seed lets two shards generate overlapping counter ranges once a test makes thousands of records. Mitigation: multiply the shard by a large stride (e.g. 1_000_000).
  • Seeding faker but not the database sequence. A per-shard RNG still collides if every shard inserts into the same auto-increment table from a fixed starting row. Mitigation: namespace the key, not just the random value.
  • Re-seeding mid-test. Calling faker.seed() again inside a test resets the stream and can re-emit values an earlier test already used. Mitigation: seed exactly once in global setup.
  • fail-fast: true hiding the collision. A cancelled matrix masks which shard lost the race. Mitigation: set fail-fast: false so every shard reports.

Reliability targets #

Metric Target How to hit it
Cross-shard data collision rate 0% Per-shard seed + namespaced keys
Seed reproducibility 100% of failures replayable Log resolved seed in global setup
Inter-shard pass-rate variance < 0.5% between shards Disjoint ranges, no shared fixtures
Suite flake rate after fix < 0.5% Combine with per-worker DB isolation

Frequently Asked Questions #

Q: Why not just give every shard a random seed instead of deriving one? A: A purely random seed prevents collisions but destroys reproducibility — when shard 3 fails you cannot replay the exact data. Deriving from the shard index gives you both: distinct ranges and a value you can reconstruct with SHARD_INDEX=3.

Q: Does this matter if each shard uses its own ephemeral database? A: Less so for DB keys, but seed collisions can still produce identical snapshot files, identical external-API request bodies, or identical S3 prefixes. Per-shard seeding is cheap insurance even with isolated databases.

Q: How does this interact with retries? A: Retries within a shard reuse that shard’s seed, which is correct — you want the retry to reproduce the same data. Cross-shard distinctness is unaffected because the offset is fixed per job.