Skip to main content
Playwright advanced Lesson 9 of 10

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.

Frequently Asked Questions

How does Playwright parallelise tests by default?
Test files run in parallel across worker processes; tests inside one file run sequentially in the same worker unless `fullyParallel` is on. Each worker is a separate Node process with its own browser, so a crash in one cannot affect another.
What does fullyParallel do?
It lets tests within a single file run in parallel too, which speeds up suites with a few large files. It also removes any implicit ordering, so tests that quietly depended on running after one another start failing — usually a bug worth fixing rather than reverting.
Should I enable retries?
Two retries on CI and zero locally is the common setting. Retries stop one flaky test from blocking a deploy, but they hide instability, so treat every `flaky` line in the report as a bug with a trace already attached rather than a result to ignore.
How do I split a Playwright suite across CI machines?
Run each machine with `--shard=i/n` and the `blob` reporter, then combine the outputs with `npx playwright merge-reports` to get one HTML report. Sharding splits by test count, so machines finish at roughly the same time.