Skip to main content
Back to Blog

Design Playwright Fixtures for Parallel Test Isolation

Going parallel does not break tests; it reveals the ones that were already sharing state. Here is how to choose fixture scope deliberately, and why parallelIndex and workerIndex are not interchangeable.

Shashank Rawlani

Engineering Leader | Builder | Problem Solver | AI Engineer

Published Sep 3, 2026Updated Sep 7, 20268 min read
Abstract diagram of four parallel worker lanes running side by side, each beginning with its own sealed fixture capsule and ending in a teardown marker, with a shared-state channel crossed out.
Quick answer: Playwright already isolates browser state per test. What it cannot isolate is state that lives outside the browser — database rows, seeded accounts, files, external systems. Fixtures are where you make that state per-test or per-worker on purpose, and parallelIndex versus workerIndex is the distinction that decides which.

Suites usually go parallel in the same order: someone sets workers: 4, a third of the tests start failing, and parallelism gets blamed. Parallelism did not break those tests. It revealed that they were sharing something, and had been getting away with it because they ran one at a time.

What you already get for free

Every Playwright test runs in its own BrowserContext. Cookies, localStorage, sessionStorage, IndexedDB, permissions, and in-memory page state are already isolated, and the context is discarded at the end of the test. This is genuinely complete — you do not need a fixture to clear cookies between tests.

It is also worth being precise about the default execution model, because people misremember it. Test files run in parallel across worker processes; tests within one file run in order in the same worker. Turning on fullyParallel changes that so all tests in all files run in parallel:

// playwright.config.ts
export default defineConfig({
  fullyParallel: true,
  workers: process.env.CI ? 4 : undefined,
});

If a specific file genuinely needs sequential execution, say so locally rather than turning parallelism off globally:

// Overrides a fullyParallel project config for this describe block only
test.describe.configure({ mode: 'default' });

Everything that breaks under parallelism lives outside that browser context. That is the whole surface area you need to think about.

The two worker indexes, and why the difference matters

This is the detail that most parallel-isolation advice gets wrong, and getting it wrong produces a bug that only appears after a retry.

A worker exposes two identifiers:

  • parallelIndex — a number between 0 and workers - 1. Workers running at the same time are guaranteed to have different values. A worker that restarts after a failure reuses its old parallelIndex.
  • workerIndex — a unique index per worker process. A restarted worker gets a new one, so values are never reused.

That produces a clean rule. Use parallelIndex for a slot: a reusable, pooled resource where you want the restarted worker to get the same one back — a seeded account, a database schema, a port. Use workerIndex when the value must never repeat, such as a directory you write to and expect to own outright.

// Slot semantics: the pool has exactly `workers` accounts, and a restarted
// worker correctly reclaims the same one.
const account = ACCOUNT_POOL[test.info().parallelIndex];

// Uniqueness semantics: a restarted worker must not reuse the old directory.
const scratchDir = `/tmp/run-${test.info().workerIndex}`;

Use workerIndex where parallelIndex belongs and your account pool needs to be unbounded. Use parallelIndex where workerIndex belongs and a retried worker will collide with the artifacts of the run that just failed. Both are available as process.env.TEST_PARALLEL_INDEX and process.env.TEST_WORKER_INDEX for tooling that runs outside the test process.

Choosing a fixture scope

A test-scoped fixture is set up and torn down around every test. A worker-scoped fixture is set up lazily before the first test in that worker that needs it, and torn down once when the worker shuts down.

The decision is a cost-versus-coupling trade, and it has one question at its centre: can a test mutate this and affect the next test?

If yes, it must be test-scoped, no matter how expensive it is. If no, worker scope is free performance.

import { test as base } from '@playwright/test';

type WorkerFixtures = { seededTenant: Tenant };
type TestFixtures = { order: Order };

export const test = base.extend<TestFixtures, WorkerFixtures>({
  // Worker-scoped: created once per worker. Safe because no test mutates the
  // tenant itself - they only create records inside it.
  seededTenant: [async ({}, use, workerInfo) => {
    const tenant = await api.createTenant(`tenant-p${workerInfo.parallelIndex}`);
    await use(tenant);
    await api.deleteTenant(tenant.id);
  }, { scope: 'worker' }],

  // Test-scoped: every test mutates its own order, so it cannot be shared.
  order: async ({ seededTenant }, use) => {
    const order = await api.createOrder(seededTenant.id);
    await use(order);
    await api.deleteOrder(order.id);
  },
});

Note the tuple syntax with { scope: 'worker' } — that is how worker scope is declared. Worker fixtures each get their own timeout, equal to the test timeout, which matters when your setup is genuinely slow.

One asymmetry worth remembering: automatic worker fixtures are set up for beforeAll hooks, but automatic test fixtures are not. If you rely on a fixture inside beforeAll, it has to be worker-scoped.

Unique data without reaching for a random number

The instinct when tests collide on a unique constraint is Date.now() or a random suffix. It works, and it costs you reproducibility — a failing run cannot be re-run against the same data, and the failure message tells you nothing about which test owned the record.

Derive the identifier from the test instead:

test('rejects a duplicate email', async ({ page }, testInfo) => {
  // Stable across runs, unique across tests, and readable in a failure
  const email = `user-${testInfo.testId}@example.test`;
  // ...
});

Apply the same thinking to files. testInfo.outputPath() returns a path scoped to the current test, which removes a whole class of parallel clobbering:

const csv = testInfo.outputPath('export.csv');
await download.saveAs(csv);

Authenticate once per worker

Logging in through the UI in every test is usually the single largest avoidable cost in a suite. The documented pattern authenticates once per worker by overriding the storageState fixture, keyed on parallelIndex — slot semantics, correctly chosen, because a restarted worker should reclaim the same account:

export const test = base.extend<{}, { workerStorageState: string }>({
  // Every test picks up the worker's stored auth state
  storageState: ({ workerStorageState }, use) => use(workerStorageState),

  workerStorageState: [async ({ browser }, use) => {
    const id = test.info().parallelIndex;
    const file = path.resolve(test.info().project.outputDir, `.auth/${id}.json`);

    if (fs.existsSync(file)) {
      await use(file);       // reuse across tests in this worker
      return;
    }

    const page = await browser.newPage({ storageState: undefined });
    await loginAs(page, ACCOUNT_POOL[id]);
    await page.context().storageState({ path: file });
    await page.close();
    await use(file);
  }, { scope: 'worker' }],
});

The pool must have at least as many accounts as you have workers, and — this is the part people miss — those accounts must not share mutable state. Two workers signed in as different users of the same tenant, both reordering the same list, are not isolated just because their cookies differ.

What actually breaks, in rough order of frequency

Shared database records. A fixture that seeds "the test product" and several tests that mutate it. Move the record into a test-scoped fixture, or make the tenant per-worker.

Order dependence hidden by file grouping. Tests within a file run in order by default, so a test that depends on its predecessor passes — until fullyParallel is enabled. If a test only passes when its neighbours ran first, it is not a parallelism bug.

Global counters and sequences. Anything that asserts "there are now three rows" is asserting about the whole table. Scope the assertion to data the test owns.

Fixed ports and fixed filenames. A mock server on :3001 in every worker. Derive the port from parallelIndex.

External sandboxes with per-account rate limits. Four workers against one payment sandbox key produces throttling that looks exactly like flakiness.

Prove it, do not assume it

Isolation is a property you can test for directly. Two commands find most of it:

# Does the suite depend on file ordering or on running one at a time?
npx playwright test --fully-parallel --workers=4 --repeat-each=3

# Does this specific test depend on its neighbours?
npx playwright test tests/orders.spec.ts --workers=1 --grep "reorders the list"

A test that passes at --workers=1 and fails at --workers=4 is sharing something. A test that fails under --repeat-each=3 in the same worker is leaking state into itself, which is a teardown bug rather than a parallelism one. The two symptoms point at different code, so it is worth running both before you start reading.

AI can find shared state; it cannot tell you what is safe to share

A model can spot the mechanical tells quickly — fixed ports, shared module-level mutable objects, beforeAll seeding that later tests mutate, Date.now() used as a uniqueness strategy. That is pattern matching over code, and it is a real speed-up on a large suite.

It cannot tell you whether two tests sharing a tenant is safe. That depends on what your application lets one user do to another's data, which is a domain fact that is not in the test file. A model will happily promote a fixture to worker scope because the suite got faster and still passed, and the resulting failure will appear weeks later as a one-in-thirty flake on CI.

Let it find candidates. Make the scope decision yourself, and write the reason in a comment next to the fixture.

Apply this now

Run your suite with --fully-parallel --workers=4 --repeat-each=3 and collect the failures. For each one, name the resource being shared. Then decide, per resource, whether it belongs in a test-scoped fixture, a worker-scoped fixture keyed on parallelIndex, or a per-worker directory keyed on workerIndex.

Write the reason down next to each fixture. The scope choice is the part of a suite that future contributors are most likely to change without understanding, and a one-line comment prevents most of that.

Frequently asked questions

Do I need to clear cookies between tests?

No. Each test gets a fresh BrowserContext. If state is surviving between tests, it is living outside the browser.

Which index should I use for a seeded account pool?

parallelIndex. It is bounded by the worker count and a restarted worker reclaims the same slot, which is exactly what a pooled account needs. workerIndex would grow unbounded across retries.

Can I use a fixture inside beforeAll?

Only if it is worker-scoped. Automatic worker fixtures are set up for beforeAll; automatic test fixtures are not.

Is workers: 1 a reasonable fix for flakiness?

It is a reasonable way to confirm the diagnosis and an expensive way to live with it. It converts a correctness problem into a wall-clock problem and hides the shared state until someone raises the worker count again.

Primary references