Isolating End-to-End Tests With Per-Worker Data

Turning on parallelism is the cheapest speed-up available to an end-to-end suite and the fastest way to make it unreliable, because the moment two workers share a database row they also share a failure. The fix is not fewer workers; it is data that belongs to exactly one worker for the duration of a run. This guide is for engineers running Playwright 1.4x against a real backend who have just watched a green suite turn intermittently red at --workers=4. It covers worker-scoped fixtures, naming that makes ownership obvious, teardown that cannot be forgotten, the queries that never parallelise safely, and how to detect leaked records before they cause a mystery failure three weeks later. It builds on the layered layout in end-to-end test architecture.

Root Cause Analysis

Parallel test failures almost never come from the browser. They come from a shared write. Two workers create an order for the same seeded customer; one asserts the customer has one order while the other has just added a second. Neither test is wrong in isolation, and both pass when run alone, which is precisely what makes the failure so expensive to diagnose — the evidence disappears the moment you try to reproduce it.

There are three distinguishable flavours. Shared-entity contention is the case above: one row, two writers. Global-query contention is subtler — a test asserts on a list, a count or a “most recent” item, and any other worker’s data changes the answer even though nobody touched the same row. Environment contention is the outlier: a single-slot resource such as one mail catcher, one webhook endpoint, or a feature flag toggled globally, where isolation requires taking turns rather than namespacing.

Recognising which flavour you have matters because the remedies differ. Shared entities want per-worker ownership. Global queries want scoped assertions — filter by the worker’s own namespace instead of asserting on a global total. Environment contention wants serialisation, and only that. Applying per-worker namespacing to a global mail catcher will not help, and serialising a suite that merely needed unique customers throws away the parallelism you were paying for.

Three kinds of contention and the remedy for each Shared-entity contention is fixed by per-worker ownership, global-query contention by scoping assertions to the worker's own namespace, and single-slot environment contention by serialising the affected tests. Shared entity two workers write the same customer row give each worker its own records Global query asserts on a total, a list or the newest row filter the assertion to its own namespace Single slot one mail catcher, one global flag serialise just those tests, not the suite
Diagnose the flavour of contention first — the three remedies are not interchangeable.

Reproducible Setup

Enable full parallelism and a fixed worker count so results are comparable between runs, and expose an administrative API the tests can create and delete data through.

npm install -D @playwright/test
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e/specs',
  fullyParallel: true,
  workers: 4,
  use: { baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000' },
});
// e2e/fixtures/api.ts — the create/delete surface tests are allowed to use
import { request } from '@playwright/test';

const admin = () =>
  request.newContext({
    baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000',
    extraHTTPHeaders: { authorization: `Bearer ${process.env.E2E_ADMIN_TOKEN}` },
  });

export async function createCustomer(name: string) {
  const api = await admin();
  const res = await api.post('/api/admin/customers', { data: { name } });
  return (await res.json()) as { id: string; name: string };
}

export async function deleteCustomersByPrefix(prefix: string) {
  const api = await admin();
  await api.delete(`/api/admin/customers?prefix=${encodeURIComponent(prefix)}`);
}

Implementation

Step 1 — Derive a namespace from the worker index. Every record a worker creates carries a prefix that identifies its owner. Include the run identifier as well, so two concurrent CI runs against a shared environment do not collide either.

// e2e/fixtures/namespace.ts
import type { TestInfo } from '@playwright/test';

export const namespaceFor = (info: TestInfo) => {
  const run = process.env.GITHUB_RUN_ID ?? 'local';
  return `t-${run}-w${info.workerIndex}`;
};

Step 2 — Create the data in a worker-scoped fixture. A worker-scoped fixture is built once per worker and reused by every test that worker runs, which is the right granularity: cheap enough to keep runs fast, isolated enough that no two workers meet.

// e2e/fixtures/index.ts
import { test as base } from '@playwright/test';
import { createCustomer, deleteCustomersByPrefix } from './api';
import { namespaceFor } from './namespace';

type WorkerFixtures = { customer: { id: string; name: string }; namespace: string };

export const test = base.extend<{}, WorkerFixtures>({
  namespace: [
    async ({}, use, workerInfo) => { await use(namespaceFor(workerInfo)); },
    { scope: 'worker' },
  ],
  customer: [
    async ({ namespace }, use) => {
      const customer = await createCustomer(`${namespace}-customer`);
      await use(customer);
      await deleteCustomersByPrefix(namespace);
    },
    { scope: 'worker' },
  ],
});

export { expect } from '@playwright/test';

Step 3 — Scope every assertion to the namespace. This is the step teams skip, and it is why suites still flake after fixtures are isolated. An assertion on “the newest customer” or “3 results” reads global state no matter how well your data is namespaced.

// good — filtered to this worker's own data
await page.getByLabel('Search customers').fill(namespace);
await expect(page.getByRole('row').filter({ hasText: namespace })).toHaveCount(1);

// bad — any other worker changes the answer
await expect(page.getByRole('row')).toHaveCount(3);
await expect(page.getByRole('row').first()).toContainText('Acme');

Step 4 — Make teardown unconditional. Cleanup written at the end of a test body is skipped whenever an assertion fails, which means the records that leak are exactly the ones from failing runs. Teardown after use() in a fixture runs regardless of outcome, and that is the whole reason to put it there.

// e2e/fixtures/order.ts — test-scoped, always cleaned up
import { test as base } from './index';

export const test = base.extend<{ order: { id: string } }>({
  order: async ({ customer }, use) => {
    const order = await createOrder({ customerId: customer.id });
    await use(order);
    await deleteOrder(order.id);   // runs even when the test fails
  },
});

Step 5 — Serialise only the tests that genuinely need a single slot. Playwright’s serial mode applies per file, so put the handful of single-slot tests in their own file rather than slowing the suite down globally.

// e2e/specs/email.serial.spec.ts — one mail catcher, so take turns
import { test, expect } from '../fixtures';

test.describe.configure({ mode: 'serial' });

test('sends a welcome email', async ({ page, namespace }) => {
  await page.goto('/signup');
  // …
  const mail = await fetchLatestMailFor(`${namespace}@example.test`);
  expect(mail.subject).toBe('Welcome');
});
Worker-scoped versus test-scoped fixtures A worker-scoped customer is created once and reused by every test on that worker, while a test-scoped order is created and deleted around each individual test, so expensive setup is shared and mutable state is not. Worker 1 customer (worker) created once order (test 1) order (test 2) created + deleted per test no state crosses a test Worker 2 customer (worker) different namespace order (test 3) order (test 4) the two workers never meet
Expensive, immutable setup goes worker-scoped; anything a test mutates stays test-scoped.

Verification

Isolation is verified by escalation. Run the suite at increasing worker counts and confirm the result does not change; a suite that passes at one worker and fails at eight has shared state, not a slow runner.

npx playwright test --workers=1 && npx playwright test --workers=8

Then run a subset, which proves no test depends on another having run first.

npx playwright test --shard=3/4 --reporter=line
# 6 passed (11.2s)   ← a quarter of the suite, passing alone

Finally, check for leaks. After a full run the environment should hold no records from that run, and a count that grows every night is the signal that some teardown path is being skipped.

# leak probe: anything left with this run's prefix is a teardown bug
curl -s -H "authorization: Bearer $E2E_ADMIN_TOKEN" \
  "$E2E_BASE_URL/api/admin/customers?prefix=t-$GITHUB_RUN_ID" | jq 'length'
# expect 0
Leaked records accumulate until an unrelated assertion breaks A timeline where a small number of records leaks from each failing run, the total grows week by week, and eventually a test that asserts on a list or a count begins failing for reasons unrelated to the change being tested. Records left behind per week week 1 week 2 week 3 week 4 week 5 a count assertion starts failing here weeks after the teardown bug shipped
A leak probe in CI turns a slow mystery into an immediate, attributable failure.

Troubleshooting

Symptom: a test fails only when the full suite runs. Diagnosis: a global assertion. Search the suite for toHaveCount, first(), last() and any use of a word like “latest” — these read state other workers can change. Fix: filter by the worker namespace before asserting, as in Step 3.

Symptom: records accumulate despite fixture teardown. Diagnosis: cleanup by identifier only, so records created indirectly — an order that spawned an invoice, a user that spawned an audit row — are orphaned. Fix: clean up by prefix rather than by id, which removes everything the namespace owns regardless of how it was created, and schedule a nightly sweep for anything older than a day.

Symptom: the worker-scoped fixture is rebuilt for every test. Diagnosis: the fixture was declared in the test-scoped position of extend, a genuinely easy mistake — worker fixtures go in the second type parameter and need { scope: 'worker' }. Fix: check the tuple form is used, and confirm by logging the fixture’s creation; you should see one line per worker, not one per test.

FAQ

Why not reset the database between tests instead?

Truncating shared tables serialises the suite by force: while one worker is resetting, every other worker’s data is gone. That works for single-worker integration runs, and resetting state between tests without slowing CI covers it, but at the end-to-end tier against a shared environment, namespacing is the only approach that keeps parallelism.

Is a timestamp enough to make a name unique?

Not on its own. Two workers starting within the same millisecond produce the same name, and a retried test reuses the timestamp of a run that already created records. Combine the worker index with a run identifier as in Step 1; the timestamp is then a nicety for humans reading the data rather than the thing guaranteeing uniqueness.

How many workers should I actually run?

Start at half your available cores and increase until wall clock stops improving — each Playwright worker needs roughly one core and a few hundred megabytes, so oversubscription makes tests slower and, worse, timing-sensitive. Whatever number you pick, make it explicit in the configuration rather than relying on the default, so local and CI runs behave alike.

What about data the application creates on its own, like audit logs?

Treat it as owned by the namespace that triggered it and clean up by prefix, which is why Step 2 deletes by prefix rather than by id. For rows that carry no name at all, either add a test-only marker column in non-production environments or accept them and sweep by age nightly — an audit row nobody asserts on is harmless until it becomes the newest row in someone’s global query.