It works on your machine, fails in CI, and passes when you try it again. That pattern often looks like a speed problem, but it is frequently a state problem. One test changes a record another test reuses, two runs write to the same file, or a fixture keeps an account that should have been private.

Playwright creates an isolated BrowserContext for each test. That protects cookies, storage, and pages. It does not automatically protect your database, queue, test account, filesystem, or a global state loaded by the application.

This article starts from the end-to-end testing guide for Playwright and answers a narrower question: how do you find and remove the shared state that CI exposes when it adds workers?

Diagram separating an isolated BrowserContext from backend data, worker fixtures, and shared files that can make Playwright tests flaky in CI.

Short answer

  • Give each test an identifier and data that no other run can reuse.
  • Use worker-scoped fixtures only for resources that are safe to share.
  • Write reports and files to paths owned by the test.
  • Use workers: 1 and retries to test a hypothesis, not to hide the cause.

Why does a test pass locally and fail in CI?

In 2026, the Playwright parallelism documentation explains that test files run in parallel by default and that flakiness appears when state lives outside a test. The difference between your laptop and CI is often the number of workers, execution order, and pressure on shared services.

The browser can be fully isolated while two requests create the same backend order. A local suite with one worker runs those requests in sequence. CI runs files at the same time and turns an invisible dependency into an observable race.

There are four boundaries worth separating during an investigation:

Boundary What Playwright isolates What you still need to isolate
Browser cookies, storage, and BrowserContext pages nothing, unless you create contexts manually
Backend nothing by default the user, order, document, or key used by the test
Worker the process and worker-scoped fixtures an account or service reused by several files
Output the runner's artifact directory filenames written by your own code

The most useful diagnosis does not start with "which timeout should I increase?" It starts with "what state exists outside this test, and who else can touch it?" That question narrows the investigation before you change configuration.

What does Playwright already isolate?

In 2026, the BrowserContext isolation page describes each context as a clean profile with its own local storage, session storage, and cookies. That guarantee covers the browser session. It does not turn a shared staging environment into a disposable environment.

A new page does not delete a row created by another test. It does not change the fixed user your whole team shares, clear a queue message, or stop two workers from writing to exports/result.csv. The word "isolated" must always name the layer that is isolated.

Here is a minimal example. It is illustrative and uses a setup function owned by the real project:

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

test("edits only the order created by this test", async ({ page }, testInfo) => {
  const orderId = `order-${testInfo.testId}`;

  await seedOrder({ id: orderId, status: "draft" });
  await page.goto(`/orders/${orderId}/edit`);
  await page.getByLabel("Status").selectOption("approved");
  await page.getByRole("button", { name: "Save" }).click();

  await expect(page.getByRole("status")).toHaveText("Order saved");
  await expect(page.getByTestId("order-status")).toHaveText("approved");
});

The value is not the order- prefix. It is deriving the data from testInfo.testId, running the scenario against that data, and checking a consequence that belongs to the same order. The Playwright parallelism guide uses this kind of identifier so tests that edit records do not race with each other.

How do you create unique data for each test?

In 2026, Playwright's best practices recommend controlling your own data when tests use a database. The fastest way to apply that rule is often to prepare the scenario through an API or seed function before opening the page. Creating the precondition by clicking through the UI adds time and mixes setup with verification.

Start by listing the resources the case touches: account, order, customer, file, queue, and feature flag. For each resource, choose one of these strategies:

  1. Generate an identifier from testInfo.testId when the resource belongs to one test.
  2. Generate an identifier from workerInfo.workerIndex when a fixture can safely be shared by every test in that worker.
  3. Use a disposable environment when the resource cannot be partitioned.
  4. Mock an external dependency that is not the subject of the test, as explained in how to mock APIs in Playwright without false positives.

Do not make data unique only in its name. Confirm that the application actually filters by that identifier and that setup fails when the resource cannot be created. A test that generates order-abc but keeps reading "the latest order" still depends on shared state.

When is a worker-scoped fixture safe?

In 2026, the Playwright fixtures documentation defines worker-scoped fixtures as resources created once for that process. That is useful for a local server or a worker-specific account. It is risky for a global account that every worker reuses.

A worker fixture needs to answer three questions: which index identifies the worker, which tests can use the resource at the same time, and how the resource will be removed when the worker ends. If one test changes what another test expects, the resource is not shareable, even if it is expensive to create.

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

type Account = { id: string; username: string };

export const test = base.extend<{}, { account: Account }>({
  account: [async ({}, use, workerInfo) => {
    const username = `ci-user-${workerInfo.workerIndex}`;
    const account = await createAccount({ username });

    await use(account);
    await deleteAccount(account.id);
  }, { scope: "worker" }],
});

The code is illustrative: createAccount and deleteAccount must exist in your test environment. The boundary is the point. Each worker gets its own account, while each test should still avoid changing data another test in that worker needs to read. When that is impossible, reduce the scope to the test.

Do not turn beforeAll into a storehouse for mutable state. Preparing an immutable configuration once can be correct. Creating an order, changing its status, and expecting every test to find it in the same state is an order dependency.

How do you avoid conflicts in files and artifacts?

In 2026, the Playwright parallelism guide recommends testInfo.outputPath() for unique paths per test. The same rule applies to exports, downloads, helper snapshots, and temporary files created by the application during the case.

import { test, expect } from "@playwright/test";
import fs from "node:fs/promises";

test("exports this order's report", async ({ page }, testInfo) => {
  const output = testInfo.outputPath("report.csv");

  await page.getByRole("button", { name: "Export" }).click();
  await expect(page.getByRole("status")).toHaveText("Export ready");
  await fs.writeFile(output, "order_id,status\nabc,approved\n", "utf8");
});

The example writes the file directly so the path is visible. In a real test, you might use the context's download directory, an application export function, or the artifact produced by CI. The rule stays the same: two tests should not be able to overwrite the same path and then argue about which content they found.

Also review global variables in the test code. A module-level result array, a singleton client with a mutable cache, or a token saved in a file can cross test boundaries even when the pages look independent.

Why are workers: 1 and retries not the fix?

In 2026, Playwright documents serial mode but recommends isolated tests instead of a suite that depends on order. Setting workers: 1 can confirm that a failure comes from concurrency. It does not prove the test is correct, because CI will expose the defect again when parallelism returns.

The same is true for retries. The retry guide classifies a test that fails on the first attempt and passes later as flaky. Repeating the run is a way to collect a signal. It is a poor way to declare that behavior reliable.

Use this diagnostic sequence:

  1. Run the case alone on the same commit and with the same data.
  2. Run it repeatedly with --workers=1.
  3. Run the same set with two or more workers.
  4. Change only the data-isolation strategy and repeat the run.
  5. Compare the first attempt, not only the result after retry.

If the failure disappears with one worker, look for external state before increasing timeouts. If it also appears in isolation, investigate waiting, locators, the environment, the network, or a wrong expectation. The assertions guide recommends web-first matchers that wait for the expected condition. page.waitForTimeout() is documented as unsuitable for stabilizing production tests.

How do you prove the cause of a flaky failure?

In 2026, Playwright recommends preserving run artifacts and using assertions that wait for observable states. Evidence for a fix should show what changed at the state boundary and which run would have failed before. A new green result alone does not explain the cause.

Record at least:

  • the commit, file, and full test name;
  • the worker and attempt that ran the case;
  • identifiers for created data, without credentials;
  • trace, console, network, and screenshot data when the case fails;
  • the difference between the first attempt and the retry;
  • the command used to repeat with one worker and with parallelism.

If the run depends on an external service, do not use the service's stability as proof of your application. The Playwright best practices recommend testing what you control and guaranteeing third-party responses when they are not the subject of the test.

A fix belongs in the suite when it changes a boundary you can name. "We increased the timeout and it passed" describes an outcome. "Each test now creates its own order, and the first retry is no longer needed" describes a testable hypothesis. Only the second sentence helps keep the suite trustworthy.

Playwright CI test review checklist

Before putting a test into parallel execution, ask:

  • Does the test create or identify the data it needs, or read a record another case left behind?
  • Do the user, token, account, and feature flag belong to the test or the worker?
  • Can the test change state another test reads?
  • Is the API mock covering an external dependency, or hiding the contract that should be verified?
  • Does every file, download, snapshot, or export have its own path?
  • Is there a mutable singleton or module-level variable?
  • Is workers: 1 a temporary diagnostic or a permanent workaround?
  • Does a retry mark flakiness when the first attempt fails?
  • Does the report preserve enough evidence to separate a bug, test, data, and environment problem?
  • Does the test wait for an observable condition, or sleep for a fixed duration?

If two answers point to the same shared resource, fix that boundary before rewriting the locator. If every dependency is isolated and the failure continues, investigate synchronization, infrastructure, or a product rule.

Frequently asked questions

Doesn't BrowserContext already isolate every test?

It isolates cookies, storage, pages, and other browser data. It does not automatically isolate a backend order, fixed account, queue, file, or singleton in your code. The test must create or partition every external resource that another run could read or change.

Can I leave every test at workers: 1?

It can be a temporary choice while the team finds a race. Playwright recommends independent tests, not a suite that depends on serial execution. If one worker removes the failure, treat that as evidence of a state or order dependency and continue the investigation.

Is a worker-scoped fixture better than a test-scoped fixture?

Not by default. Worker scope reduces setup cost when a resource is expensive and safe to share without mutation. Test scope is safer for data that changes during the scenario. Choose based on the isolation you need, not only setup time.

Should I increase retries when a test fails in CI?

Use retries to preserve evidence and identify flakiness, not to silently turn a failure into approval. A test that passes only after a retry is still unstable. Check data, accounts, files, external calls, waiting, and concurrency first. Then decide whether a limited retry has operational value.

Conclusion

Playwright CI tests become more reliable when each layer has a clear owner. BrowserContext handles the browser session. Your code must handle the records, accounts, files, queues, and global effects outside it.

Start with the failing case and write down the data it actually needs. Make that data unique, use worker fixtures only for safe resources, prefer assertions that wait for real conditions, and preserve the first attempt. Parallelism and retries are diagnostic tools. The fix is removing the hidden dependency.

Sources consulted