Parallelism, Sharding, and Configuration
Control how many workers run, when to force serial execution, how to shard a suite across CI machines, and what a retry is really telling you.
Playwright runs test files in parallel by default. Most tuning work is not making it faster — it is deciding which tests may not run in parallel, and making sure the ones that may are genuinely independent.
What runs where
npx playwright test
Running 24 tests using 5 workers
✓ 1 [chromium] › tests/orders.spec.ts:4:1 › shows the order list (703ms)
✓ 2 [chromium] › tests/profile.spec.ts:4:1 › shows the current name (688ms)
…
24 passed (8.9s)
Five workers because the default is half the machine’s logical cores. Each worker is a
separate Node process with its own browser, so tests in different workers cannot share
module state — a module-level let is not a channel between them.
// playwright.config.ts
export default defineConfig({
fullyParallel: true,
workers: process.env.CI ? 2 : undefined,
});
workers: 2 on CI is deliberate. CI containers advertise more cores than they can actually
use, and oversubscribing turns fast tests into timing-sensitive ones — the most common
cause of “flaky only in CI”.
Without fullyParallel, tests inside one file share a worker and run in order. With it,
they spread across workers:
Running 24 tests using 5 workers # fullyParallel: false → 5.2s for 6 files
Running 24 tests using 5 workers # fullyParallel: true → 3.1s
Turning it on frequently surfaces failures. That is the point: a test that only passed because another test ran first was never independent.
Forcing order when you must
test.describe.configure({ mode: 'serial' });
test.describe('checkout wizard', () => {
test('step 1: address', async ({ page }) => { /* … */ });
test('step 2: payment', async ({ page }) => { /* … */ });
test('step 3: confirm', async ({ page }) => { /* … */ });
});
Running 3 tests using 1 worker
✓ 1 [chromium] › tests/checkout.spec.ts:5:3 › checkout wizard › step 1: address (1.2s)
✘ 2 [chromium] › tests/checkout.spec.ts:9:3 › checkout wizard › step 2: payment (5.5s)
- 3 [chromium] › tests/checkout.spec.ts:13:3 › checkout wizard › step 3: confirm
1 failed
1 skipped
Serial mode means one worker, in order, and a failure skips the rest — which is honest, because step 3 could not have passed anyway. The cost is that retries re-run the whole block, and the suite is only as parallel as its longest serial chain. Use it for genuine wizards, not as a fix for shared state.
The opposite annotation, mode: 'parallel', opts a single file into parallelism when
fullyParallel is off globally.
Retries and the flaky line
export default defineConfig({
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [['github'], ['html']] : 'list',
});
Running 24 tests using 2 workers
✘ 1 [chromium] › tests/orders.spec.ts:14:1 › opens an order (5.6s)
✓ 1 [chromium] › tests/orders.spec.ts:14:1 › opens an order (retry #1) (2.0s)
1 flaky
[chromium] › tests/orders.spec.ts:14:1 › opens an order ─────────────────────
23 passed (14.2s)
The run is green and the report says flaky, not passed. Track that number — a suite
whose flaky count grows is degrading even while it stays green, and each flaky line arrives
with a trace from the failed attempt (lesson 8).
Retries also reset state: a retried test gets a fresh context, so anything the failed attempt left behind in the application is still there. That is why a test that fails, then passes on retry, sometimes fails again in the next run at a different point.
Sharding across machines
# .github/workflows/e2e.yml
jobs:
test:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npx playwright test --shard=${{ matrix.shard }}/4 --reporter=blob
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: blob-report-${{ matrix.shard }}
path: blob-report
Running 6 tests using 2 workers, shard 1 of 4
✓ 1 [chromium] › tests/orders.spec.ts:4:1 › shows the order list (712ms)
…
6 passed (4.1s)
Then merge the four blob reports into one HTML report:
npx playwright merge-reports --reporter=html ./all-blob-reports
Merging 4 blob reports…
Report written to playwright-report/index.html
Without the merge you get four partial reports and no single view of the run. Sharding splits by test count rather than duration, so one very slow test can still leave a shard running alone — split that file if it happens.
Starting the app under test
export default defineConfig({
webServer: {
command: 'npm run start',
url: 'http://localhost:3000/health',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
use: { baseURL: 'http://localhost:3000' },
});
[WebServer] > start
[WebServer] listening on :3000
Running 24 tests using 5 workers
✓ 1 [chromium] › tests/orders.spec.ts:4:1 › shows the order list (698ms)
…
24 passed (9.1s)
Point url at a health endpoint, not the homepage: Playwright polls it until it answers,
and a homepage that renders before the database is ready will start the suite too early.
reuseExistingServer keeps your local dev server if one is already running.
Global setup
webServer covers the common case. For anything else — seeding a database, minting a token
— use a setup project (lesson 6) when it needs fixtures, or globalSetup when it does not:
// global-setup.ts
export default async function () {
await resetDatabase();
process.env.RUN_ID = crypto.randomUUID();
}
export default defineConfig({ globalSetup: './global-setup.ts' });
Running 24 tests using 5 workers
…
24 passed (9.4s)
A setup project is usually the better choice: it appears in the report, it can use fixtures, and its failures are attributed to a named test rather than crashing the run before anything is reported.
Annotations and tags
test.skip(({ browserName }) => browserName === 'webkit', 'Safari lacks the API');
test.fixme('known broken until #4312 lands', async ({ page }) => { /* … */ });
test.slow(); // triples this test's timeout
test.fail(); // asserts the test currently fails
test('checkout works', { tag: ['@smoke', '@billing'] }, async ({ page }) => { /* … */ });
npx playwright test --grep @smoke
npx playwright test --grep-invert @slow
Running 4 tests using 4 workers
✓ 1 [chromium] › tests/checkout.spec.ts:8:1 › checkout works @smoke @billing (1.4s)
…
4 passed (2.9s)
fixme is better than commenting a test out: it stays in the report as a known gap instead
of disappearing from the count. fail is the one people miss — it fails if the test passes,
so a bug fix tells you to remove the annotation.
Config settings worth setting once
export default defineConfig({
forbidOnly: !!process.env.CI, // a committed test.only fails the build
maxFailures: process.env.CI ? 10 : 0,
reportSlowTests: { max: 5, threshold: 15_000 },
use: { baseURL: process.env.BASE_URL ?? 'http://localhost:3000' },
});
Running 24 tests using 2 workers
Error: focused item found in the --forbid-only mode
at tests/orders.spec.ts:14
1 error
forbidOnly catches the classic: someone commits test.only, CI runs one test, and the
pipeline is green with 23 tests silently skipped.
reportSlowTests prints the worst offenders after each run:
Slow test file: tests/reports.spec.ts (46.2s)
Consider splitting slow test files to speed up parallel execution
Practice
1. Run the suite with one worker and compare the wall time.
npx playwright test --workers=1 --project=chromium
Running 24 tests using 1 worker
24 passed (31.7s)
Against 8.9s with five workers — roughly linear, which tells you the suite is CPU-bound rather than waiting on a shared backend. If single-worker time is not much worse, the bottleneck is the server and adding workers will not help.
2. Turn on fullyParallel and find a test that breaks.
✘ 3 [chromium] › tests/orders.spec.ts:20:1 › deletes the first order (5.5s)
Error: Timed out 5000ms waiting for expect(locator).toHaveCount(expected)
Expected: 3
Received: 4
The test assumed an earlier test in the same file had already deleted a row. The fix is to seed what it needs rather than to turn the flag back off — the same failure would appear the day someone reorders the file.
3. Shard a run in two locally and merge the reports.
npx playwright test --shard=1/2 --reporter=blob
npx playwright test --shard=2/2 --reporter=blob
npx playwright merge-reports --reporter=html ./blob-report
Running 12 tests using 5 workers, shard 1 of 2
12 passed (5.4s)
Running 12 tests using 5 workers, shard 2 of 2
12 passed (5.1s)
Merging 2 blob reports…
Report written to playwright-report/index.html
Blob reports accumulate in blob-report/, so clear the directory between unrelated runs or
the merge will include stale results.
4. Commit a test.only with forbidOnly on and read the failure.
CI=1 npx playwright test
Error: focused item found in the --forbid-only mode
at tests/orders.spec.ts:14
1 error
An error rather than a test failure, raised at collection time before any browser starts — which is why it costs nothing to leave enabled.
Next: visual comparison and API testing — two things the same runner does well.