Article · Root Causes of JavaScript Test Flakiness

Isolating Database State in Parallel Jest Workers

Jest runs each test file in its own worker process for speed, but those workers share the same database by default — so one worker's INSERT becomes another worker's surprise row, a TRUNCATE in worker A wipes the fixtures worker B is mid-assertion on, and the suite fails differently on every run. This is a classic instance of Race Conditions in Parallel Test Runs, and the durable fix is to give each worker its own schema (or database) keyed off JEST_WORKER_ID, then roll back every test inside a transaction.

12 sections URL: /root-causes-of-javascript-test-flakiness/race-conditions-in-parallel-test-runs/isolating-database-state-in-parallel-jest-workers/
Shared database versus per-worker schema isolation Three Jest workers writing to one shared schema collide, while the same workers mapped to per-worker schemas keyed by JEST_WORKER_ID stay isolated. Shared schema (pollution) worker 1 worker 2 worker 3 public schema cross-writes Per-worker schema (isolated) worker 1 worker 2 worker 3 test_w1 test_w2 test_w3 JEST_WORKER_ID maps each process to a dedicated schema; transactions roll back per test.
Workers sharing one schema pollute each other's rows; mapping each worker to a schema named after JEST_WORKER_ID removes the contention entirely.

Root cause #

Jest’s default maxWorkers is roughly one per CPU core minus one, and each worker runs test files concurrently in a separate Node process. None of those processes coordinate database access — they all read the same DATABASE_URL. So if users.test.ts runs in worker 1 and accounts.test.ts runs in worker 2, and both seed a users table, worker 2’s beforeAll cleanup can delete the row worker 1’s assertion depends on. The failure surfaces in whichever worker happened to lose the timing race, which is why re-running “fixes” it and why it never reproduces under --runInBand.

The trap is that --runInBand (single worker) does make the flakiness disappear, which tempts teams to ship it permanently and eat a 4-8x slowdown. That trades a correctness problem for a throughput problem. The real boundary you need is per-worker state ownership: every worker should touch only data it created. Jest hands you exactly the key for this in process.env.JEST_WORKER_ID, a stable integer (1, 2, 3, …) assigned per worker for the life of the run. Combine a per-worker schema for coarse isolation with a per-test transaction rollback for fine isolation, and concurrent workers stop colliding without giving up parallelism.

Why re-running hides the bug Two workers race one shared row; whichever loses the timing race fails, so a re-run appears to fix it. worker A INSERT worker B TRUNCATE one shared row random failure
The failure lands in whichever worker loses the race, so a re-run "passes" without fixing anything.

Step-by-step fix #

1. Cap and expose workers so the layout is predictable #

Pin maxWorkers in CI so the number of schemas you provision matches the number of workers Jest spawns.

// jest.config.js — predictable worker count for schema provisioning
module.exports = {
  // Pin in CI so you know exactly how many per-worker schemas to create;
  // leaving it unset makes the count vary by runner core count.
  maxWorkers: process.env.CI ? 4 : '50%',
  globalSetup: './tests/global-setup.js',
  setupFilesAfterEach: ['./tests/per-test-rollback.js'],
};

2. Route each worker to its own schema via JEST_WORKER_ID #

Build the connection per worker so worker 2 never sees worker 1’s tables. Each worker resolves a schema name from its ID and sets the Postgres search_path.

// tests/db.ts — one schema per worker, derived from JEST_WORKER_ID
import { Pool } from 'pg';

const workerId = process.env.JEST_WORKER_ID ?? '1';
export const schema = `test_w${workerId}`; // test_w1, test_w2, ...

export const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export async function useWorkerSchema() {
  // search_path scopes every query in this worker to its own schema, so a
  // TRUNCATE here is invisible to other workers — coarse but bulletproof.
  await pool.query(`SET search_path TO ${schema}`);
}

3. Provision the schemas once in global setup #

globalSetup runs a single time before any worker starts, which is the right place to create each schema and run migrations into it.

// tests/global-setup.js — create + migrate every worker schema up front
const { Pool } = require('pg');

module.exports = async () => {
  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
  const workers = Number(process.env.JEST_MAX_WORKERS ?? 4);
  for (let i = 1; i <= workers; i++) {
    // Idempotent create + migrate so a re-run never errors; cost is paid
    // once, not per test, keeping CI wall-clock low.
    await pool.query(`CREATE SCHEMA IF NOT EXISTS test_w${i}`);
    await migrateInto(pool, `test_w${i}`);
  }
  await pool.end();
};

4. Roll back every test inside a transaction #

Per-worker schemas stop cross-worker pollution; a per-test transaction stops within-worker leakage between sequential tests in the same file. Begin a transaction before each test and roll it back after.

// tests/per-test-rollback.js — wrap each test, never commit
import { pool, useWorkerSchema } from './db';

beforeEach(async () => {
  await useWorkerSchema();
  await pool.query('BEGIN'); // start a savepoint the test runs inside
});

afterEach(async () => {
  // ROLLBACK is faster than TRUNCATE and leaves zero residue, so the next
  // test starts from the migrated baseline with no cleanup cost.
  await pool.query('ROLLBACK');
});

For the random data those tests insert, pair this with preventing sharded runner seed collisions so generated values stay distinct across the higher-level shards, and use JEST_WORKER_ID as the seed offset within a shard. The same isolation discipline that keeps Cypress component test races reproducible applies here: own your state, never share it.

Two isolation layers A per-worker schema isolates concurrent processes, and a per-test transaction rollback isolates sequential tests inside a worker. coarse: per-worker schema test_w1 test_w2 stops process A vs B collisions fine: per-test rollback BEGIN … ROLLBACK each test stops sequential test leakage
The schema and the transaction isolate different axes — you need both for a clean baseline every test.

Pitfalls #

  • Reading JEST_WORKER_ID at module-import time in shared config. If a config file imported before Jest forks reads the ID, it may be undefined. Mitigation: resolve the schema name lazily, inside setup hooks.
  • Connection pools that outlive the worker. A pool created in globalSetup and reused across workers shares connections and defeats isolation. Mitigation: create the pool per worker process.
  • TRUNCATE instead of ROLLBACK. Truncating between tests is slow and can deadlock under concurrency. Mitigation: wrap each test in a transaction and roll back.
  • Forgetting nested transactions. Code under test that issues its own BEGIN/COMMIT will break a naive outer transaction. Mitigation: use savepoints (SAVEPOINT/ROLLBACK TO) for the per-test boundary.
  • Schema count drift. Provisioning 4 schemas but letting Jest spawn 8 workers sends half the workers to a missing schema. Mitigation: pin maxWorkers and provision the same number.
Rollback versus truncate cost Per-test ROLLBACK leaves zero residue at low cost, while TRUNCATE between tests is slower and can deadlock. ROLLBACK fast, 0 residue TRUNCATE slow, deadlock risk Longer bar = higher per-test cost under concurrency.
Rolling back a wrapping transaction is both faster and cleaner than truncating between tests.

Reliability targets #

Metric Target How to hit it
Cross-worker data pollution 0% Per-worker schema via JEST_WORKER_ID
Per-test residue 0 rows left behind Transaction rollback in afterEach
Parallel-vs-serial pass parity 100% identical results Isolated state, no shared fixtures
Suite flake rate after fix < 0.5% Schema isolation + rollback
Speedup vs --runInBand ≥ 3x Keep maxWorkers ≥ 4 in CI
Isolation scorecard Targets for pollution, residue, pass parity, and speedup after applying per-worker isolation. 0%cross-worker 0 rowsresidue 100%pass parity ≥ 3xvs runInBand
The four numbers that confirm isolation is working across the parallel suite.

Frequently Asked Questions #

Q: Schema-per-worker or database-per-worker — which is better? A: Schema-per-worker is lighter and faster to provision on a single Postgres instance, and search_path gives you clean isolation. Reach for a full database-per-worker only when you need separate roles, extensions, or engine-level settings that a schema cannot scope.

Q: Why use both per-worker schemas and per-test transactions? A: They isolate different axes. The schema stops worker A and worker B from colliding (concurrent processes); the transaction stops test 1 and test 2 within worker A from leaking (sequential tests). You need both for a clean baseline every test.

Q: Does this work with an ORM like Prisma or TypeORM? A: Yes — set the search_path on the underlying connection and run the ORM’s queries inside the wrapping transaction. Most ORMs expose a raw-query escape hatch for SET search_path and a transaction API for the rollback.

Four Isolation Strategies and What Each Costs #

The choice of isolation strategy determines both correctness and suite duration, and the four common options have very different profiles.

Truncate between tests is the easiest to reason about and the slowest: empty the tables, re-seed reference data, run. Every test pays the cost whether or not it wrote anything, and the cost grows with table count and index rebuild time.

Transaction rollback wraps each test in a transaction that is never committed. It is effectively free and requires the test and the application to share a connection, which rules it out for browser-driven suites where the application runs in its own process. Where it fits, nothing else comes close.

A schema per worker gives total isolation and full parallelism, moving the cost to start-up: migrations run once per worker, which is negligible in a long suite and can dominate a short one.

Create-only, never clean has each test create what it needs with unique keys and assert only on its own data. Teardown disappears entirely, the database grows during the run and is discarded with the environment, and the required discipline is that no test asserts on global state.

The measurement that decides between them is simple: time the teardown and compare it with median test duration. Above roughly ten percent, the strategy is costing more than it should.

// Per-worker schema derived from the worker index — stable, not random.
// Trade-off: migrations per worker at start-up, in exchange for tests that can
// run at any worker count without interfering.
const worker = process.env.JEST_WORKER_ID ?? '1';
process.env.DATABASE_SCHEMA = `test_w${worker}`;

The Rule That Makes Any of Them Work #

Whichever strategy is chosen, one discipline determines whether it holds: no test may assert on global state.

“There are three invoices” couples a test to everything else in the database, so it passes in isolation and fails whenever another test has created something — which under parallel execution is unpredictable. “My invoice appears in the list” depends only on what this test created, and it holds regardless of what else exists, what order tests ran in, or how many workers are active.

That single change is worth more than any amount of teardown engineering, because it converts isolation from a strict requirement into an optimisation. A suite whose tests only assert on their own data can move between strategies freely, run at any concurrency, and share an environment without coordination.

The related habit is to derive every shared name from the worker index rather than from a constant: temporary paths, account addresses, external keys, ports. A quick grep for literal /tmp paths, fixed ports and hard-coded email addresses usually produces the complete inventory of collisions a suite will hit as its worker count rises.