Playwright creates a fresh BrowserContext for every test automatically, so browser state (cookies, localStorage, session) is already isolated. Application state isn't: a module-level let testUserId written by one test and read by the next will break the moment another parallel worker runs those tests out of order. This article covers the failure patterns responsible for most isolation bugs, the fixture-based createUser approach that makes cleanup unconditional, how storageState isolates authentication without sharing a live session, and the --workers=1 versus --workers=4 comparison that surfaces hidden shared state.

What test isolation actually means

Isolation means a test makes no assumptions about the world before it runs and leaves no trace after it finishes. Each test provisions whatever it needs, does its work, and the environment after that test ends is identical to the environment before it started.

That definition sounds obvious until you see what "state" actually covers in a real project. There's browser state (cookies, localStorage, session data), application state (database records, user accounts, feature flags), and test code state (module-level variables, shared fixtures with side effects). Any of these can leak between tests.

Playwright's page and context fixtures already handle browser state isolation for you. Each test automatically gets a fresh BrowserContext: a clean session with no cookies, no localStorage, nothing carried over from any other test. That's not a feature you enable; it's the default behavior. If you use the page fixture, you're already isolated at the browser level.

// Each test gets a completely fresh browser context. This is automatic.
test('anonymous user sees login button', async ({ page }) => {
  await page.goto('/dashboard');
  // No cookies, no session. Truly fresh.
  await expect(page.getByRole('link', { name: 'Log in' })).toBeVisible();
});

test('also anonymous, previous test left no trace', async ({ page }) => {
  await page.goto('/dashboard');
  // Same clean state, regardless of what ran before
  await expect(page.getByRole('link', { name: 'Log in' })).toBeVisible();
});

The hard part is application state. Playwright can't isolate your database for you. That's your job.

Classic isolation failures: patterns you'll recognize

The most common isolation failure looks like this. A test file has a setup test that creates data, a handful of tests that use that data, and a teardown test that removes it. Someone wrote it this way to avoid repeating the creation code.

// tests/user-profile.spec.ts
import { test, expect } from '@playwright/test';

// This is the shared state, the root of the problem
let testUserId: number;

test('setup: create test user', async ({ request }) => {
  const response = await request.post('/api/users', {
    data: { name: 'Test User', email: 'testuser@example.com' }
  });
  testUserId = (await response.json()).id;
});

test('can view user profile', async ({ page }) => {
  await page.goto(`/users/${testUserId}`);
  await expect(page.getByRole('heading', { name: 'Test User' })).toBeVisible();
});

test('can edit user name', async ({ page }) => {
  await page.goto(`/users/${testUserId}/edit`);
  // ...
});

test('teardown: delete test user', async ({ request }) => {
  await request.delete(`/api/users/${testUserId}`);
});

This works perfectly when tests run sequentially in file order. It breaks in four different ways once conditions change: if you enable fullyParallel, if another suite's test file deletes your test user for unrelated reasons, if the setup test fails and leaves testUserId as undefined for all downstream tests, or if someone adds this test file to a --shard split and the setup and teardown end up on different machines.

The second classic failure is the email address collision. A test creates a user with email: 'alice@test.com'. The test passes. The next time it runs, the user already exists because the previous run's teardown failed (a browser crash, a CI timeout, a test error that skipped afterAll). Now you have a 409 Conflict error that looks like a bug in your registration form.

// BAD: hardcoded email will conflict on re-run
test('register new user', async ({ page }) => {
  await page.goto('/register');
  await page.getByLabel('Email').fill('alice@test.com');
  await page.getByLabel('Password').fill('Password123!');
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByText('Welcome, alice')).toBeVisible();
});

// GOOD: unique email per run, no collision possible
test('register new user', async ({ page }) => {
  const email = `alice-${Date.now()}@test.com`;
  await page.goto('/register');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill('Password123!');
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByText('Welcome, alice')).toBeVisible();
});

Date.now() is the simplest uniqueness strategy. For more readable IDs you can combine it with a random suffix: ` alice-${Date.now()}-${Math.random().toString(36).slice(2, 6)}@test.com . The exact format doesn't matter as long as it won't collide.

Data isolation: using the API to own your test's world

The right isolation model is: each test creates everything it needs, via the API, at the start of the test, and deletes it at the end via afterEach or a cleanup fixture. No test depends on another test having created anything.

import { test, expect } from '@playwright/test';

test('admin can deactivate a user account', async ({ page, request }) => {
  // Create the data this test needs, owned entirely by this test
  const createResponse = await request.post('/api/users', {
    data: {
      name: 'Temporary User',
      email: `temp-${Date.now()}@example.com`,
      role: 'member'
    }
  });
  expect(createResponse.ok()).toBeTruthy();
  const { id: userId } = await createResponse.json();

  try {
    // The actual test
    await page.goto(`/admin/users/${userId}`);
    await page.getByRole('button', { name: 'Deactivate account' }).click();
    await page.getByRole('button', { name: 'Confirm' }).click();
    await expect(page.getByTestId('account-status')).toHaveText('Inactive');
  } finally {
    // Cleanup runs even if the test fails
    await request.delete(`/api/users/${userId}`);
  }
});

The try/finally pattern matters. If you put cleanup at the end of the test without finally, a test failure will skip the cleanup and leave orphaned data in the database. Over dozens of test runs, those orphaned records accumulate and cause unpredictable failures elsewhere.

A cleaner way to handle this in Playwright is through a custom fixture that wraps the lifecycle automatically:

// fixtures/api-fixtures.ts
import { test as base, expect } from '@playwright/test';

type ApiFixtures = {
  createUser: (overrides?: Partial<{ name: string; email: string; role: string }>) => Promise<{ id: number; email: string }>;
};

export const test = base.extend<ApiFixtures>({
  createUser: async ({ request }, use) => {
    const createdIds: number[] = [];

    const factory = async (overrides = {}) => {
      const response = await request.post('/api/users', {
        data: {
          name: 'Test User',
          email: `test-${Date.now()}-${Math.random().toString(36).slice(2, 6)}@example.com`,
          role: 'member',
          ...overrides
        }
      });
      const user = await response.json();
      createdIds.push(user.id);
      return user;
    };

    await use(factory);

    // Cleanup all users created by this test, runs after every test automatically
    for (const id of createdIds) {
      await request.delete(`/api/users/${id}`);
    }
  }
});

Now any test can use createUser and the cleanup is guaranteed:

import { test } from '../fixtures/api-fixtures';
import { expect } from '@playwright/test';

test('editor can update user profile', async ({ page, createUser }) => {
  const user = await createUser({ name: 'Jane', role: 'editor' });

  await page.goto(`/users/${user.id}`);
  await page.getByRole('button', { name: 'Edit profile' }).click();
  await page.getByLabel('Display name').fill('Jane Updated');
  await page.getByRole('button', { name: 'Save' }).click();

  await expect(page.getByRole('heading', { name: 'Jane Updated' })).toBeVisible();
});

The test is readable, the cleanup is invisible and automatic, and creating multiple users in one test is just calling createUser twice.

Build your data factories as fixtures from day one. Retrofitting them into an existing suite is much harder than starting with them. A createUser, createOrder, and createProduct fixture set covers 80% of typical e-commerce test data needs.

storageState and auth isolation: login once, stay isolated

The createUser fixture pattern handles data isolation. Authentication is a separate concern. You don't want every test to do a full browser login flow, that's slow. But you also don't want tests sharing a live browser session, because one test logging out or changing account settings will break every concurrent test.

The correct pattern: log in once per worker (not once per test, not once globally), save the authenticated storageState to a file, and load it at the start of each test from that file. Each test then gets its own browser context that starts in an authenticated state, but contexts don't share any live session.

// tests/auth.setup.ts runs once per worker before the test suite
import { test as setup, expect } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '../.auth/user.json');

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Wait until we're past the login page
  await page.waitForURL('/dashboard');
  await expect(page.getByRole('navigation')).toBeVisible();

  // Save the authentication state
  await page.context().storageState({ path: authFile });
});

// playwright.config.ts
import { defineConfig } from '@playwright/test';
import path from 'path';

export default defineConfig({
  projects: [
    {
      name: 'setup',
      testMatch: '**/auth.setup.ts',
    },
    {
      name: 'authenticated tests',
      dependencies: ['setup'],
      use: {
        storageState: path.join(__dirname, '.auth/user.json'),
      },
      testMatch: '**/*.spec.ts',
    },
  ],
});

With this setup, each test starts in an authenticated state without going through the login flow. Because storageState loads from a file into a brand-new BrowserContext, the sessions are fully isolated. What test A does to its session has no effect on test B's session.

If your app has multiple user roles (admin, editor, viewer), create a separate storageState file for each role during setup. Your fixtures can then load the right state based on what a test needs. This is much faster than logging in with different credentials inside individual tests.

Isolation is what makes parallelism safe

There is a direct relationship between test isolation and parallel execution. You cannot safely run tests in parallel if they share state, and you cannot unlock parallel execution's full benefit without proper isolation. They're two sides of the same coin.

When Playwright runs tests in parallel, different workers run simultaneously. There's no ordering guarantee between workers. If test A in worker 1 creates a user with email: 'admin@test.com' and test B in worker 2 also tries to create a user with email: 'admin@test.com', one of them will fail with a uniqueness violation. Which one? It depends on a timing race. That's the definition of a flaky test.

// playwright.config.ts. This only works if tests are truly isolated.
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,   // Every test in every file runs concurrently
  workers: process.env.CI ? 4 : '50%',
  retries: process.env.CI ? 1 : 0,
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
  },
});

fullyParallel: true is the highest-value configuration change you can make to a mature test suite. A suite of 150 tests at 3 seconds each takes 7.5 minutes sequentially. With 4 workers and proper isolation, that drops to roughly 2 minutes. The constraint isn't Playwright's capability. It's whether your tests are isolated enough to run without interfering with each other.
Don't add retries to mask isolation failures. Retries are a legitimate tool for handling genuine flakiness (network timeouts, third-party service hiccups). But if a test fails because it ran at the same time as another test and they stepped on each other's data, the retry will likely succeed, and you'll never know you have an isolation problem until it compounds into something worse. Fix the isolation, then add retries if needed.

Shared-state problems scale with worker count. One worker: the tests happen to run in an order where the state problem doesn't trigger. Two workers: you see occasional failures. Eight workers: the suite is reliably broken. If increasing workers makes your suite more flaky, that's a near-certain signal that you have shared state somewhere.

Finding isolation leaks in an existing suite

If you inherit a suite and suspect isolation problems, these are the concrete steps to find them.

Step 1: Run with
--workers=1 and compare. If the full suite passes with one worker and fails with two or more, you have an isolation problem. The tests that fail are the victims; the tests that break them are harder to find.

# Does the suite pass sequentially?
npx playwright test --workers=1

# Does it still pass with parallelism?
npx playwright test --workers=4

Step 2: Randomize the order. Some isolation bugs only appear when test A runs before test B, but they always run in the same order, so you never see the failure. Playwright doesn't have built-in order randomization, but you can split tests manually and run them in different sequences to probe for order dependencies. Step 3: Look for these code patterns specifically. Module-level variables that tests write to are the #1 culprit.

// Grep your test files for these patterns. Each is a potential isolation leak.

// Module-level variable being assigned inside a test
let userId: number;
let authToken: string;
let createdRecord: any;

// test.beforeAll creating data used by multiple tests
test.beforeAll(async ({ request }) => {
  // If anything here creates shared mutable state, you have a leak
});

// Hardcoded email addresses, user names, or any fixed unique identifiers
data: { email: 'fixed@test.com' }
data: { username: 'testadmin' }
data: { id: 1 }

Step 4: Check your cleanup paths. Search for
test.afterAll and verify that every cleanup call is also covered by afterEach or a fixture teardown. afterAll runs once per suite. If a test fails midway, afterAll still runs, but the cleanup might be operating on partial state. Step 5: Add test titles to database records. During development, name your test data after the test that creates it:

const user = await createUser({
  name: `Test user for: ${test.info().title}`,
  email: `test-${Date.now()}@example.com`
});

When you look at your test database and see ten rows named "Test user for: admin can deactivate a user account", you immediately know those are orphaned cleanup failures from that test, and which test to investigate.

How to apply this Monday morning

If you have an existing suite with isolation problems, don't try to fix everything at once. Here's a prioritized approach that delivers value immediately.

First 30 minutes: Audit for module-level variables in test files. Any
let or var declared at the module level that gets written inside a test() block is a problem. Move those declarations inside the test, use beforeEach to create fresh state, and verify the tests still pass. Next hour: Replace all hardcoded unique identifiers in test data. Email addresses, usernames, phone numbers, any field with a uniqueness constraint: make them dynamic with Date.now() or a similar strategy. This prevents the "test fails on second run" class of failures. This week: Build a createUser fixture (or whatever your most common entity is). Put the creation and deletion logic in one place, make it automatic, and migrate the five most problematic test files to use it. You'll immediately see how much simpler those tests become. This sprint: Enable fullyParallel: true with two workers and watch the failure count. Every new failure is an isolation leak that was hiding. Fix each one as it surfaces. Once the suite is clean at two workers, push to four. Keep going until you hit the machine limit or your suite finishes in under two minutes.

The goal isn't perfect isolation as an abstract principle. It's a suite that you can run with --workers=8` and trust the results. Isolation is the mechanism; fast, reliable feedback is the goal. Once your tests are stateless, parallelism is just a config value away.

→ See also: Playwright Fixtures Explained: From Built-in to Custom | Debugging Flaky Tests: A Practical Guide | Parallel Execution in Playwright: Workers, Shards, and Sharding for Speed | Flaky Tests: Why They Happen and How to Eliminate Them | Test Data Management in Playwright: Strategies and Patterns