A 200-test suite running serially at 3 seconds per test is 10 minutes of dead CI time. The same suite running on 4 workers in parallel is closer to 2 minutes 30 seconds. The same suite on 4 workers across 4 sharded CI machines is closer to 40 seconds. Parallelism is the single biggest performance lever you have in test automation, and Playwright is parallel by default — you don't opt in, you opt out for tests that can't tolerate it. This lesson is the worker model, the per-file-vs-per-test parallelism switch, and the patterns for keeping tests safe to run side by side.
Workers — the parallelism unit
A worker is a separate Node.js process. Each worker has its own browser instance, its own contexts, its own memory. Two workers running in parallel are completely isolated — what happens in one can't affect the other unless they both touch shared external state (the same database row, the same email server).
By default, Playwright runs:
- Half the logical CPUs locally — on a 16-core machine, 8 workers in parallel.
- What you tell it on CI — most CI configs explicitly set
workersbecause runners are typically smaller machines with shared resources.
// playwright.config.ts
export default defineConfig({
fullyParallel: true,
workers: process.env.CI ? 2 : undefined // 2 on CI, auto locally
});workers: undefined lets Playwright pick (half of CPUs). workers: 4 pins the count. workers: '50%' is a percentage of available cores — useful for portable configs.
fullyParallel — the file-vs-test switch
Playwright has two parallelism levels:
- Across files — every test file goes to a worker; multiple files run at once. Always on.
- Within a file — tests inside the same file run in parallel too. Controlled by
fullyParallel.
fullyParallel: true // tests within a file run in parallel
fullyParallel: false // tests within a file run sequentially (default before Playwright 1.30)Modern Playwright projects scaffold with fullyParallel: true. It's the right default — every test gets its own context, so within-file isolation is the same as across-file isolation. Set to false only if you have specific tests that must run in order (and prefer the per-describe override below).
Per-describe ordering — mode: 'serial'
Sometimes a small group of tests genuinely needs to run in order — e.g., a multi-step wizard where each step builds on the previous one. Override at the describe level:
test.describe.configure({ mode: "serial" });
test.describe("Multi-step wizard", () => {
test("step 1 — fill form", async ({ page }) => { /* ... */ });
test("step 2 — submit", async ({ page }) => { /* ... */ });
test("step 3 — confirm", async ({ page }) => { /* ... */ });
});In serial mode, tests in this describe run in order and skip subsequent tests if an earlier one fails. They share the same page if you use test.beforeAll(({ page }) => ...) for setup.
Use sparingly — every serial block undoes one parallelism win. The healthier pattern is to write each test self-sufficient (set up its own state, tear it down) so they can run in parallel safely. Reserve serial for genuine sequential flows.
What "isolated" actually means
Two parallel tests can each:
- Create their own user with a unique email
- Log in with that user
- Navigate to a different page
- Make API calls in parallel
- All without affecting each other
…because each test gets:
- Its own BrowserContext (fresh cookies, fresh localStorage)
- Its own Page (a separate tab)
- Its own
requestfixture
What they share:
- The launched browser process (Chromium starts once per worker)
- The test file source (read-only)
- The CI runner's filesystem (reads are safe; writes need care)
- External services (the same backend API, the same database, the same email server)
That last one is what trips teams up. Two parallel tests both calling POST /api/users with email: 'alice@test.com' collide on a unique-email constraint. The fix is the unique-id rule from chapter 5: every test creates its own data with a unique key.
Shared state — the rules
Three classes of shared state, three rules:
- In-memory (a global
let lastUser). Don't. It survives between tests in the same worker and breaks parallelism. If you need state, use a worker-scoped fixture. - Filesystem (a temp file). Use unique paths per test:
path.join(testInfo.outputDir, 'data.json')is whattestInfo.outputDirexists for — every test has its own. - External services (database, email, Stripe test mode). Use unique resource keys per test (timestamp + random suffix), and clean up after yourself.
If you're not sure whether your test depends on shared state, run npx playwright test --workers=4 --repeat-each=3. Twelve concurrent runs of every test. Real-world parallel collisions surface in seconds.
Speed gains, by the numbers
Rough sketch for a 200-test suite at 3 seconds per test:
Suite duration vs worker count (200 tests × 3s each)
The scaling is sub-linear at high worker counts — once you saturate CPU, RAM, or external-service rate limits, more workers stop helping. The sweet spot for most local dev is 4-8 workers; for CI it's 1-2 (because runners are smaller and parallel runs across machines via sharding does the rest).
Configuring workers per environment
The standard pattern:
import { defineConfig } from "@playwright/test";
export default defineConfig({
fullyParallel: true,
workers: process.env.CI ? 2 : "50%",
retries: process.env.CI ? 2 : 0
});Local: 50% of cores (typically 4-8 on developer machines), no retries (fail-fast feedback). CI: 2 workers (most runners are 2-4 cores; reserve some headroom for the OS), 2 retries (mask transient flake without hiding real failures).
You can override per-run from the CLI:
npx playwright test --workers=1 # force serial; useful for debugging
npx playwright test --workers=8 # force more parallelism than config saysIdentifying which test runs in which worker
The reporter shows it:
npx playwright test --reporter=listEach line includes the worker index:
[chromium] › tests/login.spec.ts:5:7 › logs in with valid credentials [worker 0]
[chromium] › tests/cart.spec.ts:3:5 › adds an item to cart [worker 1]
This is what you check when debugging "why is this one test always slow" — if every slow test ends up on worker 0, your machine has uneven core performance (efficiency cores), and pinning workers lower might help.
Test outputs are isolated too
Each test gets its own folder for screenshots, traces, and any files it writes:
test("creates a CSV", async ({ page }, testInfo) => {
const path = testInfo.outputPath("data.csv");
await fs.writeFile(path, "id,name\n1,alice\n");
// path = /test-results/.../tests-csv-creates-a-csv/data.csv
// Each test has its own folder; parallel runs don't collide
});testInfo.outputDir and testInfo.outputPath(...) are how Playwright keeps file artefacts isolated. Use them whenever a test writes to disk. Never write to a hardcoded path like /tmp/result.json — two parallel tests will trample each other.
A complete parallel-safe spec
Putting it together:
import { test, expect } from "@playwright/test";
test.describe("Parallel-safe checkout tests", () => {
// Each test creates its own user — safe to run in parallel
test("user A — completes checkout", async ({ page, request }, testInfo) => {
const email = `alice-${Date.now()}-${testInfo.workerIndex}@test.com`;
await request.post("/api/users", { data: { email, password: "pw" } });
await page.goto("/login");
await page.getByLabel("Email").fill(email);
await page.getByLabel("Password").fill("pw");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL(/dashboard/);
});
test("user B — completes checkout", async ({ page, request }, testInfo) => {
const email = `bob-${Date.now()}-${testInfo.workerIndex}@test.com`;
// Same flow, different unique user
// Runs in parallel with user A — no collision
});
});
test.describe("Sequential wizard — must run in order", () => {
test.describe.configure({ mode: "serial" });
let page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
await page.goto("/wizard/step-1");
});
test("step 1", async () => { /* fills step 1 */ });
test("step 2", async () => { /* relies on step 1 state */ });
test("step 3", async () => { /* relies on step 2 state */ });
test.afterAll(async () => { await page.close(); });
});Two patterns side by side: parallel-safe tests use unique emails per test (so 4 workers running them simultaneously don't collide); the wizard uses serial mode + a shared page in beforeAll (because steps 2 and 3 depend on step 1).
Coming from Cypress?
The mappings:
- Cypress's
--parallelflag (with Cypress Cloud) runs files across machines but not across workers locally → Playwright runs in parallel locally and across machines without paid services. - Cypress doesn't parallelise within a file by default → Playwright's
fullyParallel: truedoes. - Cypress's "shared state" gotchas (cookies, localStorage between tests in the same browser context) → Playwright's per-test BrowserContext eliminates the entire class.
If your Cypress suite was bottlenecked by sequential test execution, this is the chapter where the migration story gets dramatic. A 30-minute Cypress run becoming a 5-minute Playwright run is typical when you wire up parallelism + sharding.
⚠️ Common mistakes
- Hardcoded shared resources between parallel tests. A test that reads/writes
tests/temp.jsonor creates a database row with email'test@test.com'will collide with the second parallel run of the same test. Always usetestInfo.outputPathfor files andtestInfo.workerIndex+ timestamp for external resource keys. - Setting
workers: 1to "fix flake." It hides race conditions in your tests; it doesn't fix them. The flake comes back when CI eventually runs in parallel anyway. Diagnose the actual race (usually a missingawait, a shared cookie, or an external API rate limit), fix it, restore parallelism. - Wrapping the whole file in
test.describe.configure({ mode: 'serial' })to "make it stable." Now nothing in the file parallelises, and you've given up the biggest performance lever. Reserve serial mode for tests that genuinely depend on each other; rewrite the rest to be self-sufficient.
🎯 Practice task
Configure parallelism and measure the impact. 25-30 minutes.
-
Update
playwright.config.tsto expose worker count for measurement:import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./tests", fullyParallel: true, workers: process.env.WORKERS ? parseInt(process.env.WORKERS) : undefined, reporter: [["list"]] }); -
Create a simple test file
tests/parallel-test.spec.tswith several similar tests:import { test, expect } from "@playwright/test"; for (let i = 0; i < 8; i++) { test(`parallel test #${i}`, async ({ page }) => { await page.goto("https://www.saucedemo.com"); await page.getByPlaceholder("Username").fill("standard_user"); await page.getByPlaceholder("Password").fill("secret_sauce"); await page.getByRole("button", { name: "Login" }).click(); await expect(page).toHaveURL(/inventory/); }); } -
Run with different worker counts and measure:
time WORKERS=1 npx playwright test parallel-test.spec.ts --project=chromium time WORKERS=2 npx playwright test parallel-test.spec.ts --project=chromium time WORKERS=4 npx playwright test parallel-test.spec.ts --project=chromiumNote the wall-clock time. With one worker, all 8 tests run sequentially; with four workers, they run in batches of 4. The delta is the parallelism win.
-
Force a parallel-safety bug. Add a test at the top of the file that writes to a hardcoded path:
import { writeFileSync } from "fs"; for (let i = 0; i < 8; i++) { test(`writes to shared file ${i}`, async () => { writeFileSync("/tmp/playwright-shared.txt", `Test ${i}\n`); // Tests overwrite each other }); }Run with
WORKERS=4. Inspect/tmp/playwright-shared.txtafter — only the last test's content remains; the others got overwritten. This is the pattern that breaks at scale. -
Fix it with
testInfo.outputPath. Replace the hardcoded path:test(`writes to isolated file ${i}`, async ({}, testInfo) => { writeFileSync(testInfo.outputPath("data.txt"), `Test ${i}\n`); });Re-run. Each test now writes to its own folder; no collisions.
-
Stretch: add a
test.describe.configure({ mode: 'serial' })block with three dependent tests (e.g., "fill step 1", "fill step 2", "submit"). Run withWORKERS=4. Note the serial block runs in order even when parallelism is high. Open the HTML report — the three tests are grouped, and a failure in step 1 shows the others asskippedrather than running and failing pointlessly.
You now know how to set the parallelism dial and how to keep tests safe at high worker counts. The next lesson takes parallelism across machines — sharding, the technique that turns "two minutes on a beefy laptop" into "thirty seconds across four CI runners."