Playwright architecture, selector reliability, and advanced interaction patterns.

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.

Worker-scoped fixtures across parallel processes Two worker processes each own one fixture instance and a private resource, while tests share the per-worker instance. Worker 0 fixture (worker) db pool 0 tests share one instance Worker 1 fixture (worker) db pool 1 tests share one instance External resource isolated per worker
Each worker process gets its own fixture instance and private resource; the tests inside a worker share that one instance, and nothing crosses worker boundaries.

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.

The distinction matters because Playwright's default parallelism model runs each test file in its own worker and, with fullyParallel: true, spreads individual tests across the pool as well. That gives you real OS-level process isolation for free: a global variable set in one worker is invisible to the others because they do not share a heap. What they do share is anything living outside the process — a database, a message queue, an external auth provider — and that shared surface is exactly where a test-scoped or singleton resource turns a green suite red. Scoping to the worker draws the isolation boundary in the right place, giving each process a private handle onto the shared system while keeping the setup cost paid once rather than once per test.

Choosing between test scope and worker scope

Not every fixture belongs at worker scope. The decision hinges on two questions: how expensive is the resource to build, and does sharing one instance across a worker's tests risk cross-test contamination? A per-test authentication token is cheap and must stay isolated, so it stays at the default test scope. A connection pool is expensive to open and safe to share within a process, so it belongs at worker scope. Getting this wrong in either direction is costly — over-scoping leaks state between tests that assumed a clean slate, while under-scoping rebuilds an expensive resource on every test and erases the parallel speed-up you set out to gain.

Test scope versus worker scope comparison A matrix contrasting test-scoped and worker-scoped fixtures across creation frequency, best use, isolation boundary, and teardown timing. Aspect Test-scoped Worker-scoped Created once per test once per worker Best for cheap per-test state pools, tokens, seeds Isolation per test per process Teardown after each test on worker exit
Reach for worker scope when a resource is expensive to build and safe to share within a process; keep isolated per-test state at the default test scope.

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);
});

The lifecycle of that fixture within a single worker is a strict sequence: Playwright calls the fixture function once, before the worker's first test; the function builds the pool, runs the health check, then suspends at use() while every test in the worker borrows the same instance; only when the worker has no more tests does control return past use() to run teardown. The diagram traces that order across the three participants involved.

Worker-scoped fixture lifecycle sequence A sequence diagram showing the worker, the fixture, and the database pool exchanging setup, validation, use, and teardown messages in order. Worker process Fixture (worker) DB pool before first test new Pool() SELECT 1 validate use(pool): tests share worker exit await pool.end()
The fixture builds and validates the pool once, suspends at use() while the worker's tests borrow it, and only tears down after the last test — the awaited pool.end() is what keeps the socket from leaking.

Step-by-step fix

  1. 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.
  2. Allocate the resource asynchronously. Inside the fixture, create the pool, client, or token with await so initialization fully completes before control returns.
  3. Validate connectivity before use(). Run a cheap health check such as SELECT 1 and throw a descriptive error if it fails, so a misconfigured worker fails fast instead of producing confusing downstream errors.
  4. Hand the resource over with use(). Call await use(resource); every test in that worker receives the same instance as a fixture parameter, with no manual instantiation.
  5. Derive per-worker data from workerIndex. Use workerInfo.workerIndex to build unique credentials, schemas, or seed keys so two workers never collide on the same data.
  6. Await teardown after use(). Place await resource.close() (or pool.end()) after use(); 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.

A subtler check guards against silent over-scoping. Because a worker-scoped resource persists across a worker's tests, a test that mutates it can leak state into the next test in the same process even though isolation across workers is intact. Add an assertion at the top of a follow-on test that the resource is in its expected baseline state — for a pool, that the same seeded row count holds — and if that assertion drifts as the suite grows, a test is mutating shared state that should be reset per test or moved back to test scope. The mechanics of building these expensive-once handles cleanly are the subject of Worker-Scoped Fixtures for Expensive Setup, which extends the pattern here to browser instances and seeded tenants. Treat the fixture module as the single source of truth for how a resource is created, shared, and released, and keep test files free of any direct instantiation so the scoping decision lives in exactly one place.

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.

Back to overview