PRACTICAL GUIDE / Playwright fullyParallel shared state
Stop fully parallel tests from corrupting shared state
Find and remove data, account, and file collisions before enabling full Playwright parallelism, with fixtures, diagnostics, and a safe rollout path.
In this guide8 sections
- Understand what full parallelism changes
- Reproduce the collision before fixing it
- Separate a cross-test collision from one owner submitting twice
- Give mutable backend records a test owner
- Allocate accounts, files, and scarce resources at the right scope
- Tell a state collision from an ordinary flaky wait
- Roll out parallelism without moving the flakiness around
- Put each part of the fix with its real owner
What you will learn
- Understand what full parallelism changes
- Reproduce the collision before fixing it
- Separate a cross-test collision from one owner submitting twice
- Give mutable backend records a test owner
Two order tests pass when their file runs by itself. Turn on fullyParallel, and both workers create order-1; one request returns 201 while the other gets 409. The browser sessions are isolated, but the database row is not.
This failure is useful. It exposes ownership that the old execution order was hiding. The durable fix is to give each mutable resource one test, worker, or run identity, then make cleanup follow the same owner.
Understand what full parallelism changes
Playwright normally runs test files in parallel while tests inside one file run in declaration order. Setting fullyParallel: true at the configuration or project level lets tests inside every file run in parallel too. Those tests execute in separate operating-system worker processes. They cannot coordinate through JavaScript variables, and each test executes the relevant hooks for itself, including beforeAll and afterAll in a parallel group.
That process boundary creates a counterintuitive failure. Consider this helper:
let nextOrder = 1;
export function newOrderReference(): string {
return `order-${nextOrder++}`;
}In the old single-worker file, the first test receives order-1 and the second receives order-2. Under full parallelism, two workers load separate copies of the module. Both counters start at 1. The workers are not racing on one JavaScript number; they independently produce the same supposedly unique value and then collide in a system they both reach.
Browser isolation solves a different problem. The built-in page fixture gives each test a fresh browser context, so cookies, local storage, session storage, and page history do not leak between those tests. It does not create a private database, mail inbox, object-store bucket, payment sandbox, feature flag, tenant, or filesystem directory. Anything outside the context still needs a naming or allocation scheme.
Hooks also change meaning. A beforeAll that creates one customer and an afterAll that deletes it may run once per parallel test rather than once for the file. If all copies use customer@example.test, setup races. If one test finishes early, its cleanup can delete the customer while another test is still using it. Moving the same code into a helper does not change this lifecycle.
Retries add another boundary. After a test failure, Playwright discards the worker process and starts a new one. workerIndex changes for the replacement process. parallelIndex remains the same worker slot. A module cache, open database client, or in-memory lease disappears, while server-side data created by the failed attempt can remain.
Use the identities according to their scope:
| Identity | Stable for | Good use | Dangerous assumption |
|---|---|---|---|
testInfo.testId | The logical test | Per-test row, tenant, inbox, or prefix | It identifies a separate CI run by itself |
testInfo.retry | One attempt number | Distinguishing retry-owned records and artifacts | A retry should always use new product data |
parallelIndex | A concurrent worker slot, including restarts | Selecting an account from a fixed pool | It identifies one logical test |
workerIndex | One worker process lifetime | Logs and process diagnostics | It remains stable after a failure |
testInfo.outputPath() | One test result directory | Downloads, exports, screenshots, temporary evidence | Another test can find it at a shared pathname |
The run itself also needs identity when several pipelines use the same environment. A test id that is unique inside one invocation can still meet the same id from a second invocation. Supply a run value such as QA_RUN_ID from the CI system, and fail setup if a shared environment requires it but it is missing. Do not silently substitute a constant in CI.
Reproduce the collision before fixing it
Make the race visible with a small test rather than diagnosing it through a later UI timeout. This intentionally bad example uses the module counter and retains the backend's response:
// tests/orders/order-collision.spec.ts
import { expect, test } from '@playwright/test';
let sequence = 1;
for (const currency of ['USD', 'EUR']) {
test(`creates a ${currency} order`, async ({ request }, testInfo) => {
const externalId = `order-${sequence++}`;
const response = await request.post('/test-support/orders', {
data: { externalId, currency },
});
await testInfo.attach('create-order-response.json', {
body: JSON.stringify({
externalId,
status: response.status(),
body: await response.text(),
workerIndex: testInfo.workerIndex,
parallelIndex: testInfo.parallelIndex,
retry: testInfo.retry,
testId: testInfo.testId,
}, null, 2),
contentType: 'application/json',
});
expect(response.status()).toBe(201);
});
}Run the file once with one worker, then stress the same configured project. Keep retries off so a passing second attempt cannot replace the first collision in your mental model.
npx playwright test tests/orders/order-collision.spec.ts \
--workers=1 --retries=0 --reporter=line
npx playwright test tests/orders/order-collision.spec.ts \
--workers=8 --repeat-each=20 --retries=0 --reporter=lineThe relevant failure record is not merely Expected: 201, Received: 409. Open the attachment and look for the duplicate externalId paired with different testId, workerIndex, or currency values. A representative backend body might be:
{
"code": "ORDER_ALREADY_EXISTS",
"externalId": "order-1"
}That evidence rejects a locator problem and a slow server. Two independently owned cases attempted the same unique key. If the responses are 429 instead, the shared boundary is probably an account, IP, or service rate limit. If both return 201 but a later read sees the wrong currency, the backend may be using an upsert or last-write-wins operation that hides the create collision.
Do not start by adding random sleeps. A delay changes which worker wins and may reduce frequency, but it leaves the duplicate key. Raising retries makes the report greener while preserving data corruption. Reducing the whole suite to one worker hides the bug and gives up concurrency for every independent test.
A close near-miss has a different signature. Suppose every test uses a unique order id, but both log in as the same administrator and change the account's default currency. The order records do not collide. The account setting does. Evidence shows different order ids with the same user id and overlapping setting updates. Fix account ownership, not the order helper.
Separate a cross-test collision from one owner submitting twice
The same status and business key can come from a different root cause. One test can submit the create operation twice because the page issued a duplicate request, an intermediate service replayed an attempt, or the test repeated an action after an uncertain response. The visible failure can still be a 409 naming order-1. Changing the key generator would make the next run green while leaving the duplicate-submission defect intact.
The attachment above proves competing test owners only when identity fields disagree in the right way. Group evidence by run id, project, business key, logical testId, and retry. Two overlapping creates for one key from different logical test ids point to a test ownership collision. Two creates carrying the same test id and retry point to one attempt producing duplicate work. If the browser trace contains one request but backend logs contain two request records, the replay occurred beyond the browser. If the trace itself contains two creates after one user action, the page or test flow created both requests.
Read each field for its scope. A healthy isolated create has one run id, one test id, one retry value, one business key, and one successful backend request record. A broken cross-test case retains the run id and business key but shows two different test ids whose request intervals overlap. A broken duplicate-submission case retains all of those ownership fields and instead shows more than one request record for the single attempt. The backend request identifier is valuable here because two records with distinct identifiers demonstrate two handling attempts, while one identifier repeated on several log lines may only be one request passing through multiple components.
workerIndex is a misleading discriminator after a failure. The same logical test can move to a new worker for its retry, so different worker indexes do not establish two owners. parallelIndex can remain unchanged during that replacement, but it still identifies a slot rather than the cause of a request. The response code is also insufficient. A 409 can represent a uniqueness constraint, an optimistic concurrency check, or an application rule unrelated to duplicate data. Preserve the response body and the backend request identifier, then connect the response to the operation that wrote or rejected the record.
Timing separates another close case. Two test ids that use the same key hours apart are more likely seeing stale cleanup or a reused run identity than a live race. Overlapping attempts with different keys followed by a shared-account failure implicate account state. For every collision investigation, retain test start and end times plus the server timestamps for create, update, and delete. Use timestamps from the same clock domain to establish ordering and overlap. Do not infer precise client-to-server order unless those clocks are known to be synchronized.
The fix follows the evidence. Competing test owners need partitioned resources. One owner submitting twice needs the application or intermediary to prevent an unintended replay, or the product contract to handle an intended replay safely. Stale cleanup needs lifecycle repair. These changes may all remove a 409 from CI, but they protect different production behavior.
Give mutable backend records a test owner
Derive a compact external key from the run, project, logical test, and attempt. Hashing avoids illegal characters and keeps database fields within their limit.
// tests/support/case-key.ts
import { createHash } from 'node:crypto';
import type { TestInfo } from '@playwright/test';
export function caseKey(testInfo: TestInfo): string {
const configuredRunId = process.env.QA_RUN_ID;
if (process.env.CI && !configuredRunId)
throw new Error('QA_RUN_ID is required when tests run in CI');
const runId = configuredRunId ?? 'local';
const input = [
runId,
testInfo.project.name,
testInfo.testId,
String(testInfo.retry),
].join(':');
return createHash('sha256').update(input).digest('hex').slice(0, 16);
}Use a test-scoped fixture when creation and cleanup form one resource lifecycle. Teardown after await use() runs even when the assertion fails normally.
// tests/fixtures.ts
import { expect, test as base } from '@playwright/test';
import { caseKey } from './support/case-key';
type OrderFixture = {
order: { id: string; externalId: string };
};
export const test = base.extend<OrderFixture>({
order: async ({ request }, use, testInfo) => {
const externalId = `e2e-${caseKey(testInfo)}`;
const created = await request.post('/test-support/orders', {
data: { externalId, currency: 'USD' },
});
const createdBody = await created.text();
expect(created.status(), createdBody).toBe(201);
const order = JSON.parse(createdBody) as { id: string; externalId: string };
try {
await use(order);
} finally {
const deleted = await request.delete(`/test-support/orders/${order.id}`);
expect([204, 404]).toContain(deleted.status());
}
},
});
export { expect } from '@playwright/test';// tests/orders/cancel-order.spec.ts
import { expect, test } from '../fixtures';
test('cancels an open order', async ({ page, order }) => {
await page.goto(`/orders/${order.id}`);
await page.getByRole('button', { name: 'Cancel order' }).click();
await expect(page.getByText('Cancelled')).toBeVisible();
});The fixture accepts 404 during cleanup because the test may exercise product deletion. That trade-off must be intentional. Accepting every cleanup error would hide a broken support endpoint. If deletion is a required product outcome, let the test own that assertion and make fixture cleanup idempotent.
Including retry gives each attempt a fresh external key. This avoids a failed attempt's leftover row poisoning the retry, but it also means the retry does not reproduce against the identical record. For idempotency or recovery tests, omit the retry and make setup fetch or reset the existing record. Choose based on the product claim, not convenience.
Never use a timestamp alone. Two workers can read the same millisecond, and a timestamp does not tell a reviewer which case owned the row. A random UUID prevents collision but weakens diagnosis unless the test attaches it. A hashed case key gives uniqueness and a stable mapping while the attachment preserves the source identities.
Allocate accounts, files, and scarce resources at the right scope
Creating a user for every test gives strong isolation but can dominate runtime. When tests may share an account safely inside one worker, allocate one account per parallel slot. parallelIndex is usually better than workerIndex for a fixed account pool because it remains stable when Playwright replaces a failed worker.
// tests/account-fixtures.ts
import { expect, test as base } from '@playwright/test';
type WorkerFixtures = {
workerAccount: { username: string; password: string };
};
export const test = base.extend<{}, WorkerFixtures>({
workerAccount: [async ({ browser }, use, workerInfo) => {
const configuredRunId = process.env.QA_RUN_ID;
if (process.env.CI && !configuredRunId)
throw new Error('QA_RUN_ID is required when tests run in CI');
const runId = configuredRunId ?? 'local';
const username = `qa-${runId}-${workerInfo.parallelIndex}@example.test`;
const password = 'test-only-password';
const baseURL = workerInfo.project.use.baseURL;
if (typeof baseURL !== 'string')
throw new Error('This fixture requires use.baseURL in Playwright config');
const setupPage = await browser.newPage({ baseURL });
await setupPage.goto('/test-support/signup');
await setupPage.getByLabel('Email').fill(username);
await setupPage.getByLabel('Password').fill(password);
await setupPage.getByRole('button', { name: 'Create test user' }).click();
await expect(setupPage.getByText('User ready')).toBeVisible();
await setupPage.close();
await use({ username, password });
const cleanupPage = await browser.newPage({ baseURL });
await cleanupPage.goto(`/test-support/users/delete?email=${encodeURIComponent(username)}`);
await expect(cleanupPage.getByText('User deleted')).toBeVisible();
await cleanupPage.close();
}, { scope: 'worker' }],
});
export { expect } from '@playwright/test';Adapt the support flow to the application, but keep the scope rule. Concurrent slots get different accounts. Tests later reused by one worker still need to restore settings they mutate. A worker account is not permission for state to leak from one sequential test to the next.
Authentication state can be generated once per worker and written to a filename based on parallelIndex. Do not have every worker overwrite playwright/.auth/user.json. Likewise, do not select accounts using array[testInfo.workerIndex]; worker indexes continue increasing after failures and can exceed a fixed pool.
Here is a worker-scoped storage-state pattern for a pre-provisioned account pool. QA_USER_0, QA_USER_1, and their matching password variables are suite conventions in this example, not Playwright settings.
// tests/authenticated-test.ts
import { mkdir } from 'node:fs/promises';
import path from 'node:path';
import { expect, test as base } from '@playwright/test';
type WorkerFixtures = {
workerStorageState: string;
};
export const test = base.extend<{}, WorkerFixtures>({
storageState: ({ workerStorageState }, use) => use(workerStorageState),
workerStorageState: [async ({ browser }, use, workerInfo) => {
const slot = workerInfo.parallelIndex;
const username = process.env[`QA_USER_${slot}`];
const password = process.env[`QA_PASSWORD_${slot}`];
if (!username || !password)
throw new Error(`Missing credentials for parallel slot ${slot}`);
const authFile = path.join(
workerInfo.project.outputDir,
'.auth',
`slot-${slot}.json`,
);
await mkdir(path.dirname(authFile), { recursive: true });
const baseURL = workerInfo.project.use.baseURL;
if (typeof baseURL !== 'string')
throw new Error('This fixture requires use.baseURL in Playwright config');
const page = await browser.newPage({ baseURL, storageState: undefined });
await page.goto('/signin');
await page.getByLabel('Email').fill(username);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByTestId('account-email')).toHaveText(username);
await page.context().storageState({ path: authFile });
await page.close();
await use(authFile);
}, { scope: 'worker' }],
});
export { expect } from '@playwright/test';The separate filenames prevent partial JSON writes and wrong-account reads. The separate accounts prevent two contexts with valid cookies from editing the same server-side preferences. Both layers matter. Fixing only the file path can leave a perfectly parsed state file that authenticates every worker as one mutable user.
Using parallelIndex also makes a retry select the same credential slot after its worker process is replaced. That stability saves another login allocation, but it means a failed attempt may have changed the account. Reset mutable preferences during fixture setup or provide accounts whose product data is namespaced further by testId. If the identity provider invalidates old sessions on each login, do not regenerate the same slot concurrently in a second CI run; allocate run-specific pools or coordinate outside Playwright.
Files have the same ownership problem. This export test writes and attaches a path that Playwright guarantees is scoped to the current test:
import { expect, test } from '@playwright/test';
test('downloads the invoice CSV', async ({ page }, testInfo) => {
await page.goto('/billing/invoices');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export CSV' }).click();
const download = await downloadPromise;
const csvPath = testInfo.outputPath('invoice.csv');
await download.saveAs(csvPath);
await testInfo.attach('invoice.csv', { path: csvPath, contentType: 'text/csv' });
expect(download.suggestedFilename()).toBe('invoices.csv');
});Putting every export in ./downloads/invoice.csv creates a race between saveAs calls and cleanup. Adding the worker index to that filename reduces collision within one process layout but still clashes across simultaneous CI jobs. The test result directory already solves both problems when each job has its own output root.
Some resources cannot be partitioned: a physical card reader, one destructive database migration target, or a vendor sandbox with a single global switch. Put those cases in a dedicated project with one worker. Exclude them from the fully parallel project so they do not run twice.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'parallel-chromium',
fullyParallel: true,
testIgnore: /exclusive\.spec\.ts/,
},
{
name: 'exclusive-resources',
workers: 1,
fullyParallel: false,
testMatch: /exclusive\.spec\.ts/,
},
],
});test.describe.configure({ mode: 'default' }) opts a group out of full parallel behavior inside its file. It does not stop another file or project from touching the same external resource at the same time. A one-worker project gives the exception a visible scheduling boundary. If several CI jobs share the resource, the project still needs an external lease or separate environment because Playwright only coordinates workers inside its own run.
Tell a state collision from an ordinary flaky wait
Run one-worker and many-worker cases against the same build and data policy. A failure that disappears with one worker is a concurrency clue, not conclusive proof. Higher concurrency can also overload CPU, database pools, rate limits, or the web server.
Attach a compact identity record automatically for every failed test:
// Add to a shared fixture module.
import { test as base } from '@playwright/test';
export const test = base.extend<{ isolationEvidence: void }>({
isolationEvidence: [async ({}, use, testInfo) => {
await use();
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach('isolation-evidence.json', {
body: JSON.stringify({
testId: testInfo.testId,
retry: testInfo.retry,
workerIndex: testInfo.workerIndex,
parallelIndex: testInfo.parallelIndex,
processId: process.pid,
project: testInfo.project.name,
runId: process.env.QA_RUN_ID ?? null,
}, null, 2),
contentType: 'application/json',
});
}
}, { auto: true }],
});Pair that record with the business key, account id, response status, and backend request id returned by the application. Trace Viewer shows the failing test's actions and network traffic, but it does not merge another worker's trace into the same timeline. The duplicate key across two attachments or backend log entries is what connects the cases.
Use the first non-success response, not the final locator timeout. A 409 with the same external id points to record collision. A 429 with the same account points to quota sharing. A 404 after another test's cleanup points to overlapping ownership. A 500 with database pool exhaustion and unique case keys points to capacity rather than shared logical state.
Worker restart evidence is distinctive. If attempt zero uses workerIndex: 3, fails, and retry one uses workerIndex: 9 with the same parallelIndex: 2, Playwright replaced the process. A worker-scoped resource named from workerIndex will change. A pool slot named from parallelIndex will remain. Server data that survives both attempts must be reset, made idempotent, or included in attempt ownership.
One especially misleading failure starts as expect(locator).toHaveText() timing out. The trace shows the page still says "Saving," but the network response is 409 or 429. The locator is reporting a product state downstream of the real conflict. Increasing the assertion timeout gives the application more time to display a result that can never become success.
Roll out parallelism without moving the flakiness around
Inventory mutable boundaries before changing the global flag. Search for module counters, fixed emails, hard-coded tenant names, shared storage-state paths, common download directories, singleton mailboxes, and beforeAll data creation. Review support APIs for upserts that silently let one test overwrite another.
In a suite that already gates releases, land evidence collection before changing resource names or concurrency. Add the run identity at the CI boundary and attach the ownership tuple on failures while the old execution model remains in place. This establishes whether separate jobs already collide and reveals consumers that assume a fixed email, tenant, or output path. It also gives the before-state needed to tell a real improvement from failures that merely changed shape.
Next, migrate one resource class at a time. Land the deterministic key helper and its cleanup behavior together for disposable records. Land account-pool validation before any project selects accounts by parallel slot. Land test-scoped output paths before increasing workers that can write files concurrently. A partial migration is intentionally visible: fixtures that still look up a fixed identity, cleanup jobs that only recognize the old prefix, and assertions that embed a shared account name are usually the first things to break.
Do not raise concurrency in the same change that introduces every ownership fixture. First prove the new fixture at the old worker count, including a forced assertion failure and a retry, so teardown and attempt policy are exercised. Then use the canary project to increase concurrency. Finally widen directory coverage while comparing failure categories, created-resource counts, cleanup failures, and slowest-job duration with the old project. The rollout is working when collisions disappear without a rise in orphaned data, quota errors, or missing tests.
Create a fully parallel canary project for a small independent directory. Run it with high worker count and --repeat-each while retries remain zero. Provision enough backend capacity that the stress run measures ownership rather than an artificially tiny connection pool. Keep the current project as the comparison until failures are classified.
Move resource creation into fixtures by scope. Per-test fixtures provide the strongest isolation and the most setup cost. Worker fixtures reduce login or provisioning time but require reliable reset between tests. Run-scoped seeded data is fastest when it is truly immutable. Record which choice each fixture makes and why.
Expect concrete costs. Unique accounts and tenants consume database rows and cleanup time. Per-test API setup adds latency. A worker pool needs enough credentials for the maximum worker count in every browser project. A one-worker exclusive project lengthens the critical path. Parallel traffic can require larger service limits than production-like serial smoke tests.
Validate pool capacity before the first browser launches. If the project requests eight workers but CI supplies four accounts, reducing the effective worker count implicitly makes runtime unpredictable and can still let another project reuse those accounts. Fail setup with the missing slot number, or set the project's workers value to the documented pool size. When Chromium and Firefox projects run together, remember that worker slots are scheduled across projects but the account policy still needs to prevent cross-project mutation. Attach the selected account identity without its password so a collision can be traced.
Cleanup should have its own dashboard or scheduled repair path for runs killed by the CI platform. Fixture teardown covers assertion failures, but no test runner can finish deletion after a machine is terminated abruptly. Prefix disposable rows with the run identity and creation time, then let a bounded janitor remove expired test data. The janitor is a safety net, not a substitute for fixture teardown during normal runs.
Do not use mode: 'serial' to preserve tests that depend on each other's side effects unless the sequence itself is the product scenario. Serial groups retry together, skip later tests after a failure, and prevent independent diagnosis. A multi-step workflow usually belongs in one test with named steps, while independent outcomes need independent setup.
Hold back cases that mutate a single global resource until the resource can be partitioned or externally leased. That is not a failure of parallel testing. It is an accurate statement about the system. Keep the exception small, named, and measured instead of turning off full parallelism for thousands of unrelated cases.
Finish the rollout only when the suite passes in a different order, with repeated runs, with retries disabled, and across the intended shard count. A green run at four workers does not prove safety at four shards against one shared environment. The ownership model must include process, machine, and CI-run boundaries, not merely the browser context visible in the test.
Put each part of the fix with its real owner
The test-platform owner should maintain the identity helper, fixture scopes, failure attachment, and rules for retries. The CI owner should provide a unique run identity, isolate output roots between jobs, and size or distribute credential pools. The service team that owns the mutated record should define uniqueness, cleanup, and replay behavior. The shared-environment owner should monitor quotas and remove expired test resources after interrupted runs. Assigning all four responsibilities to the test author leaves the parts outside the repository unowned.
A useful handoff contains the run id, project, test id, retry, worker and parallel indexes, business key, account or tenant id, first non-success response body, backend request identifiers, and ordered create and cleanup timestamps. Include whether the same key appeared under another test id and whether the browser emitted one request or more than one. State the smallest concurrency that reproduces the failure and whether it survives a new run identity. That packet lets the service team inspect duplicate writes, the CI team inspect job overlap, and the test team inspect scope without each team collecting a different rerun.
The isolation choice has a measurable operational cost even when it works. Per-test provisioning adds a create and cleanup lifecycle to every case that uses the fixture. Worker-owned accounts avoid that repeated setup but require reset logic after every mutating case and enough distinct slots for all jobs sharing an environment. Retry-specific records prevent a leftover row from poisoning the next attempt, but they reduce coverage of recovery against the original record. The exclusive one-worker project preserves correctness for a scarce resource at the direct cost of serial critical-path time.
Resource isolation does not catch a real lost-update or double-edit bug between legitimate concurrent users. Unique test-owned rows deliberately prevent two cases from touching the same record. A product feature that promises safe concurrent editing needs a separate, intentional concurrency scenario whose actors share one record and whose synchronization and expected conflict behavior are explicit. Accidental isolation failures and deliberate concurrency coverage should not be treated as substitutes for each other.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Why do tests in one file collide only after fullyParallel is enabled?
Tests from that file can move into separate worker processes and run at the same time. Each process gets its own copy of module globals, so a counter that looked shared may generate the same value in several workers.
Does Playwright isolate backend data between parallel tests?
Browser contexts isolate cookies, local storage, and other browser state. Database rows, user accounts, files, queues, rate limits, and third-party sandboxes remain shared unless the suite gives them explicit ownership.
Should test data use workerIndex or parallelIndex?
Use `parallelIndex` for a stable worker slot that must survive a worker restart. Prefer `testInfo.testId` for records owned by one test, and add a run identity when separate CI jobs can reach the same environment.
How do I stop parallel tests overwriting the same download?
Write the artifact under `testInfo.outputPath()`, which returns a path inside that test's output directory. Attach or inspect that path instead of copying every result to a shared filename.
When is one Playwright worker the right choice?
A dedicated one-worker project is reasonable for a genuinely exclusive resource such as a hardware device, destructive migration, or global vendor setting. Keep independent tests in the parallel project so the exception does not become the suite's default.
RELATED GUIDES
Continue the learning route
GUIDE 01
Protect Authentication State in Playwright Agent Workflows
Master Playwright agent authentication state with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 02
Playwright Authentication Guide for Passkeys and Browser State
Playwright Authentication Guide for Passkeys and Browser State: practical implementation, debugging, evidence, security, CI, and release guidance for QA teams.
GUIDE 03
Playwright Component Testing for React State, Events, and Routing
Test React components in a real browser with Playwright mount fixtures, semantic locators, callback assertions, route hooks, and controlled data.
GUIDE 04
Retrying Async State with expect.poll and expect.toPass in Playwright
Use Playwright expect.poll and expect.toPass for bounded asynchronous checks, with intentional intervals, idempotent probes, and useful failures.
GUIDE 05
Refresh Expiring Playwright Storage State in CI
Master Playwright expiring storageState refresh pipeline with implementation examples, failure analysis, evidence design, CI controls, and release-ready QA checklists.