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.

10 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.

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.

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.

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

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.