Worker-Scoped Fixtures for Expensive Setup
Some setup is cheap enough to repeat: building a page object, creating a temp directory, generating a random email. Other setup is not. Provisioning a tenant through an internal API, importing a fixture dataset, minting an OAuth token, or launching a stub service costs seconds, and paying that cost before every single test turns a four-minute suite into a twenty-minute one. Playwright's answer is the worker-scoped fixture: a value that is constructed once per worker process, handed to every test that process runs, and torn down when the process exits. This page shows how to identify setup worth amortizing, how to write the fixture so it survives worker restarts and slow networks, and how to prove the amortization actually happened. It applies the scoping model introduced in Playwright Config & Fixtures, part of Playwright Setup & Core Architecture.
Root cause: the default fixture scope rebuilds the value for every test
A fixture declared with the plain function form is test-scoped, so Playwright constructs it before each test and destroys it after — that isolation is exactly what you want for a page, and exactly what you do not want for a database import that takes eight seconds. Because the suite runs one process per worker, the natural unit for an expensive-but-reusable value is the process, not the test. Declaring the fixture with the tuple form and { scope: 'worker' } moves construction to the first test in the process that requests it, and moves destruction to worker shutdown, so the cost is divided across every test that worker executes.
Cost model: when worker scope actually pays for itself
The decision is arithmetic, not taste. If setup costs c seconds and a worker runs n tests, test scope spends c × n seconds and worker scope spends c — the saving is c × (n − 1) per worker. With eight workers, 240 tests and a two-second setup, test scope burns eight minutes of pure provisioning while worker scope burns sixteen seconds. Below roughly 100ms the bookkeeping is not worth it, and above a second the case is usually decisive.
The arithmetic only holds when the value is safe to share. A worker-scoped fixture is a shared mutable object for every test in that process, so the second test sees whatever the first test did to it. Values that qualify are read-mostly or self-partitioning: an authenticated API client, a frozen catalogue of reference data, a compiled schema, a launched stub server with per-test routes, a seeded tenant whose tests only append rows scoped to themselves. Values that do not qualify are anything a test mutates in a way another test can observe — a shopping cart, a draft document, a counter. For those, keep the expensive part at worker scope and derive a cheap private copy at test scope.
Order matters too. Playwright builds fixtures lazily and only when a test actually requests them, so a worker fixture that no test in a given worker depends on is never constructed at all — which means a suite filtered with --grep can legitimately skip the provisioning entirely. Teardown then runs in reverse dependency order at worker shutdown, so a client fixture that another fixture depends on is disposed last, after its dependants have finished cleaning up.
Browser-level state is a separate axis. Playwright already gives you a worker-scoped browser fixture and a test-scoped context and page, so a fresh, isolated profile per test costs almost nothing; you rarely need to widen that yourself, and the trade-offs are covered in Browser Contexts & Isolation. Authentication is the classic hybrid: mint the credential once per worker, then load it into each test's context as described in Reusing Login State with storageState.
Minimal reproducible example
The fixture below provisions a tenant through an internal API — roughly eight seconds of work — and reuses it for every test in the worker. Two details make it production-grade: it depends only on other worker-scoped fixtures, and it names its remote resource after parallelIndex so a restarted worker reattaches to the same slot instead of provisioning a new one.
import { test as base, type APIRequestContext } from '@playwright/test';
// Worker fixtures are declared in the SECOND type parameter of base.extend.
type WorkerFixtures = {
apiClient: APIRequestContext;
tenant: { id: string; adminToken: string };
};
export const test = base.extend<{}, WorkerFixtures>({
// The built-in `playwright` fixture is worker-scoped, so depending on it is legal here.
apiClient: [async ({ playwright }, use) => {
const client = await playwright.request.newContext({ baseURL: process.env.API_URL });
await use(client);
await client.dispose(); // awaited teardown; runs at worker shutdown
}, { scope: 'worker' }],
tenant: [async ({ apiClient }, use, workerInfo) => {
// parallelIndex is bounded by --workers, so it names a slot that can be re-entered.
const slot = `e2e-slot-${workerInfo.parallelIndex}`;
const created = await apiClient.post('/internal/tenants', {
data: { name: slot, reset: true }, // server-side upsert: reuse or rebuild the slot
});
if (!created.ok()) {
// Fail loudly here rather than letting every test time out on a missing tenant.
throw new Error(`tenant provisioning failed for ${slot}: ${created.status()}`);
}
await use(await created.json()); // one object shared by every test in this worker
// Teardown must tolerate a half-provisioned world after a mid-run worker kill.
await apiClient.delete(`/internal/tenants/${slot}`).catch(() => undefined);
}, { scope: 'worker', timeout: 60_000 }], // slow setup gets a budget of its own
});
export { expect } from '@playwright/test';
Step-by-step fix
- Measure the setup before you scope it. Wrap the candidate work in
console.time()or read the "Before Hooks" duration in the HTML report, then multiply by the number of tests a worker runs (total tests ÷ workers). If the product is under a second or two of aggregate time, leave the fixture test-scoped — the isolation is worth more than the milliseconds. - Declare it in the worker type slot with the tuple form. Worker fixtures go in the second type parameter of
base.extend<TestFixtures, WorkerFixtures>(), and the value must be written as[fn, { scope: 'worker' }]. Omitting the options object silently leaves the fixture test-scoped, which is the single most common reason an "amortized" setup still runs on every test. - Depend only on worker-scoped fixtures. A worker fixture may request
playwright,browser, and your own worker fixtures, but neverpage,context,request, orbrowserName-derived test fixtures. Playwright validates this at load time and refuses to run the suite, because a per-process value cannot be built from a per-test one. - Give slow setup its own timeout. Fixture options accept
timeout, as in{ scope: 'worker', timeout: 60_000 }. Without it, setup time is charged against the first test's timeout, so a slow provisioning call makes an unrelated test fail with a message about setting up your fixture. A dedicated budget keeps the failure honest and the test timeout tight. - Key external resources by
parallelIndex, notworkerIndex.workerInfo.parallelIndexis bounded by the worker count and is guaranteed unique among simultaneously running workers, so it is the right key for schemas, ports, tenants, and directories.workerIndexkeeps increasing across worker restarts, so using it to name remote resources leaks a new one every time a test fails. - Keep the shared value read-only, and derive per-test state from it. Freeze the object, or expose only accessor methods, and add a cheap test-scoped fixture that copies out whatever a test needs to mutate. This preserves per-test isolation while keeping the expensive construction at process level.
- Make teardown awaited and idempotent. Put cleanup after
await use(...), await it, and write it so it succeeds when setup only half-completed — a worker can be killed between provisioning steps. Swallow "already deleted" errors rather than throwing, since an exception in teardown is reported as a worker error with no owning test.
The second snippet shows the read-only pattern from step 6: the costly value stays at worker scope and frozen, while a cheap test-scoped fixture hands each test a private copy it can mutate freely.
import { test as base, expect } from '@playwright/test';
import { readFile } from 'node:fs/promises';
type WorkerFixtures = { catalogue: readonly string[] };
type TestFixtures = { cart: { items: string[] } };
export const test = base.extend<TestFixtures, WorkerFixtures>({
catalogue: [async ({}, use) => {
// Parsing and validating this file is the expensive part; do it once per process.
const raw = await readFile('./fixtures/catalogue.json', 'utf8');
const items: string[] = JSON.parse(raw).map((entry: { sku: string }) => entry.sku);
await use(Object.freeze(items)); // frozen: a test cannot corrupt it for its neighbours
}, { scope: 'worker', box: true }], // box hides the fixture step from the report tree
// Test-scoped and cheap: a private, mutable copy derived from the shared value.
cart: async ({ catalogue }, use) => {
await use({ items: catalogue.slice(0, 2) });
},
});
test('a test may mutate its own copy', async ({ cart }) => {
cart.items.push('SKU-EXTRA'); // safe: nothing outside this test sees the change
expect(cart.items).toHaveLength(3);
});
Troubleshooting variants
Playwright refuses to start with a worker-versus-test fixture error
If the run aborts before any test executes with a message stating that a worker fixture cannot depend on a test fixture, one of your worker-scoped functions destructured page, context, request, or another test-scoped fixture from its first argument. The dependency graph is validated when the config loads, so this is a hard error rather than a runtime surprise. Remove the test-scoped dependency and rebuild the value from worker-scoped primitives: use playwright.request.newContext() instead of request, and browser.newContext() instead of context. If a worker fixture genuinely needs something a test provides, the design is inverted — split it into a worker part that builds the shared resource and a test part that adapts it.
The expensive setup runs far more often than the worker count
Three mechanisms multiply fixture construction, and they compose. Every project runs its own workers, so a three-browser matrix triples the count — see Cross-Browser Execution. Every worker that hosts a failing test is retired and replaced, so a flaky suite with retries can rebuild the fixture dozens of times; the retry settings that drive this are in Configuring Retries and Timeouts for Stable CI. And every CI shard is a separate machine with its own worker pool, which is why sharded pipelines described in Running Playwright Tests in GitHub Actions with Sharding multiply provisioning load. Make the setup re-entrant against a parallelIndex slot so a rebuild is a cheap reattach rather than a full provision.
Tests pass individually but corrupt each other inside one worker
A suite that is green with --workers=1 --fully-parallel=false and red at full width, with failures that move around between runs, usually means tests are mutating the shared worker value. Reproduce it deterministically by running a single file with npx playwright test path/to/file.spec.ts --repeat-each=3, which keeps all repetitions in one worker and forces the second execution to observe the first one's damage. The fix is step 6: freeze the shared object and derive mutable per-test state. If the mutation is remote rather than in memory — rows in a seeded tenant, files in a shared bucket — namespace the writes with a per-test key such as test.info().testId so two tests in one worker never touch the same record.
Verification
Prove the amortization rather than assuming it. The cheapest check is an in-process counter: because module state is per worker, a counter incremented inside the fixture must still read 1 in every test of that worker if the scope is correct.
import { test as base, expect } from '@playwright/test';
let buildsInThisProcess = 0; // module scope means one counter per worker process
export const test = base.extend<{}, { expensive: string }>({
expensive: [async ({}, use, workerInfo) => {
buildsInThisProcess += 1;
console.log(`worker ${workerInfo.workerIndex} / slot ${workerInfo.parallelIndex}: build ${buildsInThisProcess}`);
await use('ready');
}, { scope: 'worker' }],
});
test('setup is amortized across the worker', async ({ expensive }) => {
expect(expensive).toBe('ready');
expect(buildsInThisProcess).toBe(1); // fails instantly if the fixture is test-scoped
});
Keep that assertion in the suite permanently; it costs nothing and it is the only guard that catches a scope regression the moment someone edits the fixture file. Run it with npx playwright test --workers=4 --repeat-each=5: the log line should appear at most once per worker, and the assertion fails immediately if the tuple form or the scope option was lost in a refactor. Next, compare wall-clock totals — record the suite duration before and after the change and confirm the delta matches c × (tests − workers); a much smaller saving means the fixture is being rebuilt by restarts or project fan-out. Finally, open a trace and inspect the "Before Hooks" section for the second and third tests of a worker, where the worker fixture should be absent because it was already constructed; the reading technique is covered in Analyzing Test Failures with the Playwright Trace Viewer, and the same durations surface in the HTML report described in Reporters & Test Artifacts.
Frequently Asked Questions
How is a worker-scoped fixture different from test.beforeAll?
A beforeAll hook runs once per worker per describe block or file, so a suite of thirty files re-runs it thirty times in the same process, and it has no typed return value — you end up storing results in module-level variables that TypeScript cannot help you with. A worker-scoped fixture is constructed once for the whole process regardless of how many files run, is injected as a typed parameter, and has a teardown phase that Playwright awaits before the process exits.
Can a worker-scoped fixture use the page fixture?
No. page, context, and request are test-scoped, and Playwright rejects the configuration at load time if a worker fixture depends on any of them, because a value that outlives the test cannot be built from one that does not. Build what you need from the worker-scoped browser and playwright fixtures instead — browser.newContext() and playwright.request.newContext() both work inside a worker fixture, provided you dispose of them in teardown.
Why does my worker fixture rebuild even though nothing changed?
Playwright retires a worker process whenever a test in it fails, and starts a replacement for the remaining work, so setup is repaid once per failure. Each configured project also gets its own workers, and each CI shard its own pool. Treat rebuilds as expected and make provisioning re-entrant against a slot key so the replacement can reattach rather than build from nothing.
Does worker scope reduce how many workers I should run?
Usually the opposite: amortized setup makes higher worker counts cheaper, because the fixed cost per process is paid once instead of scaling with the tests that process runs. The limiting factor becomes whatever the fixture allocates — connection limits, seeded tenants, licence seats — so size the worker count against those quotas rather than against CPU alone.