Setting Up Global Fixtures for Parallel Tests
Running tests in parallel turns a slow suite into a fast one, but it also exposes every assumption that a test is alone in the world. A shared database connection, a singleton HTTP client, or a single set of credentials that worked fine serially starts producing intermittent failures the moment --workers rises above one. Playwright gives each parallel worker its own Node.js process, and the tool for handing each of those processes its own copy of an expensive resource is a worker-scoped fixture. This page shows how to build worker-scoped fixtures that allocate a resource once per worker, validate it before any test runs, and guarantee asynchronous teardown so nothing leaks between workers. It is the applied version of Playwright Config & Fixtures, which sits under Playwright Setup & Core Architecture.
Root cause: parallel workers fight over a single shared resource
When a fixture is test-scoped or a resource is a module-level singleton, every worker ends up pointing at the same connection pool, the same auth token, or the same seeded rows. Serially that is harmless because only one test touches it at a time. In parallel, two workers race to bind the same socket, refresh the same token, or mutate the same data, and the result is intermittent failures that never reproduce on a single worker. The cure is to scope the expensive resource to the worker rather than the test, so each Node.js process allocates exactly one private instance. A worker-scoped fixture initializes that instance before the worker's first test, every test in that worker reuses it, and Playwright tears it down only when the worker exits — provided the teardown is awaited.
Minimal reproducible example
The fixture below creates one PostgreSQL pool per worker, verifies connectivity before any test runs, hands the pool to the tests, and closes it on worker shutdown. The { scope: 'worker' } option is what makes it run once per process instead of once per test.
import { test as base } from '@playwright/test';
import { Pool } from 'pg';
type WorkerDb = { dbPool: Pool };
export const test = base.extend<{}, WorkerDb>({
// Tuple form: [fixtureFn, options]; scope 'worker' = one instance per process.
dbPool: [async ({}, use, workerInfo) => {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
});
// Validate connectivity BEFORE any test runs; fail loudly if it cannot connect.
try {
await pool.query('SELECT 1');
} catch (error) {
await pool.end(); // release the half-open pool before surfacing the error
throw new Error(`DB init failed in worker ${workerInfo.workerIndex}: ${(error as Error).message}`);
}
await use(pool); // every test in this worker shares this one pool
await pool.end(); // awaited teardown runs only when the worker exits
}, { scope: 'worker' }],
});
export { expect } from '@playwright/test';
import { test, expect } from './fixtures';
test('reads a seeded row', async ({ dbPool }) => {
const { rows } = await dbPool.query('SELECT count(*) FROM users');
expect(Number(rows[0].count)).toBeGreaterThan(0);
});
Step-by-step fix
- Decide the scope deliberately. Choose
{ scope: 'worker' }for anything expensive or stateful (connection pools, auth tokens, seeded data) so it is created once per process, and leave cheap per-test state at the default test scope. - Allocate the resource asynchronously. Inside the fixture, create the pool, client, or token with
awaitso initialization fully completes before control returns. - Validate connectivity before
use(). Run a cheap health check such asSELECT 1and throw a descriptive error if it fails, so a misconfigured worker fails fast instead of producing confusing downstream errors. - Hand the resource over with
use(). Callawait use(resource); every test in that worker receives the same instance as a fixture parameter, with no manual instantiation. - Derive per-worker data from
workerIndex. UseworkerInfo.workerIndexto build unique credentials, schemas, or seed keys so two workers never collide on the same data. - Await teardown after
use(). Placeawait resource.close()(orpool.end()) afteruse(); the worker will not exit until that promise resolves, which prevents leaked sockets and port conflicts.
Troubleshooting variants
Teardown leaks sockets or ports between workers
The most common cause is a missing await on cleanup. When the use() call resolves, Playwright moves on immediately, so an un-awaited close() lets the next worker try to bind a still-open socket. Make the teardown line await resource.close(); and confirm the worker waits for it. The same discipline applies to authentication state saved per worker, which pairs naturally with isolated sessions from How to Configure Multiple Browser Contexts in Playwright.
Two workers overwrite each other's test data
If parallel runs corrupt seeded rows, the workers are sharing a data namespace. Derive every mutable identifier from workerInfo.workerIndex — for example test_user_${workerIndex} or a per-worker schema — so each process owns a disjoint slice of data. Log the index during initialization to confirm each worker really got a distinct value.
A fixture re-initializes on every test instead of once per worker
If you see the expensive setup run for each test, the fixture is still test-scoped. Switch to the tuple form with { scope: 'worker' } as the second element; without that option Playwright treats the fixture as test-scoped and rebuilds it every time, defeating the memoization. Aligning these scopes with your overall configuration is part of Playwright Config & Fixtures.
Verification
Confirm correct scoping three ways. First, log workerInfo.workerIndex and a creation marker in the fixture, then run with --workers=4 and verify the setup line appears once per worker, not once per test. Second, run the suite under repetition (npx playwright test --repeat-each=5 --workers=4) and watch for stable results; flakiness here points to a shared resource or a missing teardown await. Third, capture a trace with --trace on and review per-worker activity in the Playwright Trace Viewer to confirm no resource crossed a worker boundary. Wiring worker counts and retries into pipelines is covered by CI/CD Integration.
Frequently Asked Questions
What is the difference between globalSetup and a worker-scoped fixture?
globalSetup runs exactly once before any worker starts and is right for one-time provisioning such as seeding a database or writing an auth token to disk. A worker-scoped fixture runs once per worker process and is right for per-process resources like a connection pool that each worker must own privately.
How do I make sure fixture teardown actually completes?
Always await the cleanup call after use(), for example await pool.end(). Playwright keeps the worker alive until that promise resolves, so an un-awaited teardown is the usual source of leaked sockets and port conflicts under parallel runs.
How do I avoid test data collisions across workers?
Derive every mutable identifier from workerInfo.workerIndex, such as unique usernames, schemas, or seed keys. Because each worker has a distinct index, this guarantees two processes never write to the same data namespace at the same time.