Article · Root Causes of JavaScript Test Flakiness

Eliminating Test Order Dependence in Jest

Order dependence is the flakiness that disappears the moment you try to reproduce it: the file passes on its own, fails in the full run, and passes again after a rerun because Jest scheduled the workers differently. This guide takes the diagnosis method from Test Isolation & State Leakage and turns it into a repeatable procedure — randomise with a recorded seed, bisect to the polluting test, fix at the writer, then keep the suite honest with a permanent shuffle in CI.

12 sections URL: /root-causes-of-javascript-test-flakiness/test-isolation-and-state-leakage/eliminating-test-order-dependence-in-jest/
Why order dependence looks intermittent Jest assigns files to workers by availability, so the polluting file and the victim only share a worker on some runs. run 1 worker 1 writes global flag victim — fails same worker run 2 worker 1 writes global flag worker 2 victim — passes different worker the code did not change between runs — only the file-to-worker assignment did so a rerun "fixes" it and the bug stays in the suite
Worker assignment, not the code, decides whether an order-dependent test fails on a given run.

Root cause #

Jest runs test files in separate worker processes and, by default, orders them by file size — largest first — to keep workers busy. Within a worker, module state, global object properties, process.env writes and installed spies persist across every file that worker handles. A test that writes to globalThis, mutates a module-level cache or leaves Date patched therefore pollutes an unpredictable subset of the suite: whichever files happen to land behind it.

Two properties make this hard to chase. The pollution is transitive — the failing test may be several files downstream of the writer — and it is sensitive to worker count, so a laptop with eight cores and a CI runner with two produce entirely different failure sets. The consequence is a category of failure that reruns appear to fix, which is why order dependence tends to accumulate for years in suites where reruns are the standard response. Making the ordering deterministic and adversarial is what converts it from an intermittent mystery into a reproducible bug.

Step-by-step fix #

1. Randomise with a recorded seed #

Jest 29 added --randomize, which shuffles the order of test files and of tests within a file. Paired with an explicit --seed, a red run becomes exactly replayable.

# Trade-off: shuffling raises the chance of catching order dependence but makes
# every failure seed-specific — the seed MUST be printed in the CI log to be useful.
npx jest --randomize --seed=20260802 --maxWorkers=2

# Reproduce a CI failure locally with the same seed and worker count:
npx jest --randomize --seed=20260802 --maxWorkers=2 --runInBand

Matching --maxWorkers matters as much as the seed. With a different worker count the same seed produces a different file-to-worker mapping, and the failure will not reproduce.

2. Bisect to the polluting file #

Once a seed reproduces the failure, run the ordered file list and halve it. --runInBand forces a single process so the ordering is fully determined by the list you pass.

# Trade-off: runInBand is slower than parallel execution but is the only mode
# where "the tests that ran before this one" is an exact, controllable list.
npx jest --runInBand \
  src/a.test.js src/b.test.js src/c.test.js src/victim.test.js

Keep the victim last, halve the preceding files, and repeat on whichever half stays red. Six rounds isolates one polluter out of sixty-four files. When the red half shrinks to a single file, run that file’s tests with -t filters to find the specific test.

Bisection procedure with the victim pinned last Each round halves the candidate files that run before the victim and keeps the half that reproduces the failure. 32 candidate files victim (pinned) 16 — red, keep 16 — green, drop victim 8 — red 8 — green victim five rounds from 32 files to one — then filter within that file with -t
Pinning the victim last keeps the failure condition constant while the candidate set shrinks.

3. Fix at the writer, and enforce the reset in config #

Once the polluter is named, the fix belongs there — not in the victim. The three writers responsible for the overwhelming majority of cases are globals, environment variables and unrestored spies.

// Trade-off: these globals make the test terse, but they are worker-wide state
// and every file after this one inherits them until the process exits.
beforeEach(() => {
  globalThis.__FEATURE_FLAGS__ = { newCheckout: true };
  process.env.LOCALE = 'de-DE';
});

afterEach(() => {
  delete globalThis.__FEATURE_FLAGS__;   // undo what this file installed
  delete process.env.LOCALE;
});

Then make the whole class of leak impossible from configuration, so a new spec cannot reintroduce it:

// jest.config.js
// Trade-off: resetModules re-evaluates the dependency tree per file, which
// costs time on heavy imports — apply it globally only if the delta is small.
module.exports = {
  restoreMocks: true,   // spies uninstalled after every test
  resetModules: true,   // fresh module registry per test file
  clearMocks: true,     // call history wiped between tests
  randomize: true,      // shuffle by default, locally and in CI
};

The same principles applied to module-scope caches are covered in Resetting Module Mocks and Singletons in Vitest; Jest’s API names differ but the ladder from clear to reset to restore is identical.

4. Keep a shuffled run in CI permanently #

A one-off cleanup regresses. Run the suite shuffled on every pipeline, print the seed in the log, and feed the results into Historical Flakiness Tracking & Analytics so a new order dependence shows up as a trend rather than a surprise.

# .github/workflows/test.yml
# Trade-off: a per-run seed maximises the chance of catching new order
# dependence, but each failure needs its seed to reproduce — so echo it.
- name: Unit tests (shuffled)
  run: |
    SEED=${GITHUB_RUN_ID}
    echo "jest seed: $SEED"
    npx jest --randomize --seed="$SEED" --maxWorkers=2
Fix at the writer, not the reader Adding a workaround to the failing test moves the problem to the next unlucky test; resetting at the writer removes it. writer leaks globalno teardown victim patchedreads leak, works around next victimsame leak, new symptom writer resetsafterEach + config every downstream test unaffected — one fix, whole class removed
Patching the victim converts one reproducible bug into an unbounded series of unrelated-looking ones.

5. Know the four writers that cause almost every case #

Bisection tells you which file; experience tells you what to look for once you are in it. In practice four patterns account for the overwhelming majority of order dependence in JavaScript suites.

The first is a write to globalThis or to a global that the runtime shares — fetch, crypto, Date, console — replaced and never put back. The second is process.env, which is process-wide and therefore worker-wide; a test that sets NODE_ENV or a feature flag changes the code path for every later file in that worker. The third is a module-scope collection: a cache, a registry, an array of subscribers that grows across tests. The fourth is an external resource — a temporary file, a database row, a port binding — created with a fixed name so a second test in a different worker collides with the first.

// A leak-detection helper: snapshot the shared surfaces and diff them.
// Trade-off: it catches only what you enumerate, but it names the writer
// immediately instead of leaving the failure to surface downstream.
const snapshot = () => ({
  env: { ...process.env },
  globals: Object.keys(globalThis).sort().join(','),
});

let before;
beforeEach(() => { before = snapshot(); });
afterEach(() => {
  const after = snapshot();
  expect(after.globals).toBe(before.globals);
  expect(after.env).toEqual(before.env);
});

Dropping that helper into a suspect file turns “something in here leaks” into a named key on the first run, and it is cheap enough to leave permanently in the setup file for suites where order dependence has been a recurring problem.

6. Decide what a shuffle-only failure means #

A failure that appears only under a shuffled order is a real bug in the suite, not an environmental flake, and it deserves different handling from a timing flake: it is deterministic given the seed, so it can be reproduced, bisected and fixed rather than quarantined. Encode that distinction in the pipeline by running the shuffled suite as its own required check, excluded from any blanket retry policy, so it cannot be silently retried into green. The retry-budget reasoning behind that separation is covered in Flaky Test Detection & Quarantine Engineering.

Pitfalls #

  • Reproducing without matching --maxWorkers. The seed alone does not determine which files share a worker. Mitigation: record and replay the worker count with the seed.
  • Bisecting with parallel workers on. The “files that ran before” set is non-deterministic. Mitigation: bisect with --runInBand.
  • Patching the victim. The leak survives and reappears elsewhere. Mitigation: always fix at the writer.
  • Using --runInBand in CI to make the suite green. Serialisation hides order dependence between workers and multiplies wall-clock time. Mitigation: keep parallelism, fix the isolation.
  • Shuffling without printing the seed. The failure becomes unreproducible, which is worse than not shuffling. Mitigation: echo the seed as the first line of the test step.

Reliability targets #

Metric Target Notes
Shuffle seeds with identical results 20 / 20 Run nightly across seeds
Bisection time to name a polluter < 15 min Six --runInBand rounds on a 64-file suite
Files with manual global writes 0 Enforced by lint rule on globalThis assignment
Seed printed in CI log 100% of runs Precondition for reproducibility
Order-dependent failures reaching main 0 per quarter Shuffle on every pipeline
Order-independence scorecard Targets for seed stability, bisection time, global writes and seed logging. 20/20seeds agree < 15 minto name a polluter 0global writes 100%seeds logged
A suite that gives the same answer under twenty seeds has no order dependence worth chasing.

Frequently Asked Questions #

Q: The failure will not reproduce locally even with the CI seed. What am I missing? A: Almost always the worker count. Jest’s shuffle is applied to a file list that is then distributed across workers, so eight local workers and two CI workers produce different groupings from the same seed. Match --maxWorkers to the CI value first; if it still will not reproduce, the cause is environment rather than order — see CI Environment & Browser Drift.

Q: Should I just run the suite with --runInBand in CI? A: No. Serial execution makes the symptom go away by removing the parallelism that exposed it, at the cost of a much longer pipeline, and the dependency remains for anyone who runs the suite differently. Use --runInBand as a debugging tool only.

Q: Bisection points at a file that looks completely unrelated to the failure. Is that plausible? A: Very. The polluter and the victim are connected by a shared surface, not by a shared feature — a global, an env var, a module cache — so a payments test can break a search test through a stubbed fetch neither of them owns. Read the suspect file for writes to shared state rather than for topical similarity.

Q: Should the shuffled run block merges, or run nightly? A: Start nightly to work through the backlog without blocking anyone, then promote it to a required check once the count reaches zero. Blocking on a suite that still has thirty known order dependencies just trains people to retry the check; blocking on a suite that is clean keeps it clean.

Q: How do I stop new order dependence from being merged? A: Shuffle on every pipeline with a per-run seed and treat a shuffle-only failure as a blocking bug rather than a retry candidate. Enabling restoreMocks and resetModules in jest.config.js removes the two largest sources before they are written.