Playwright Test Sharding: Running Tests Across Multiple Machines

Playwright Test Sharding: Running Tests Across Multiple Machines

End-to-end tests written with Playwright are inherently slow. Each test spins up a real browser, navigates through actual UI flows, and waits for network responses. A suite of 300 E2E tests running sequentially can take 45 minutes — long enough that most teams either stop running E2E tests in CI or accept the slow feedback as unavoidable.

Playwright's built-in sharding makes that tradeoff unnecessary. With the --shard flag and a matrix job in GitHub Actions, those 300 tests run across 10 machines simultaneously and finish in under 5 minutes. This guide covers the complete setup: sharding configuration, GitHub Actions matrix, report merging, coverage combination, and the cost versus speed tradeoffs you need to know.

Playwright's Built-In Sharding

Playwright added native sharding in version 1.15. Unlike third-party solutions, it's built into the test runner and requires no additional packages.

# Run shard 1 of 3
npx playwright test --shard=1/3

# Run shard 2 of 3
npx playwright test --shard=2/3

# Run shard 3 of 3
npx playwright test --shard=3/3

Playwright distributes tests across shards by splitting the list of test files. The split is deterministic — given the same test files and the same shard count, shard 1 always gets the same files. Tests within a file always run together on the same shard, which matters because beforeAll/afterAll hooks in a file must run on the same machine.

GitHub Actions Matrix Setup

The standard pattern uses a matrix strategy to parameterize the shard index:

# .github/workflows/playwright.yml
name: Playwright Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    name: Playwright (shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shardIndex: [1, 2, 3, 4, 5]
        shardTotal: [5]

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Run Playwright tests
        run: |
          npx playwright test \
            --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}

      - name: Upload blob report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: blob-report-${{ matrix.shardIndex }}
          path: blob-report
          retention-days: 1

The if: always() on the upload step is critical — it ensures the blob report is uploaded even when tests fail. Without it, you can't see which tests failed in the merged report.

Installing Playwright Browsers Efficiently

Playwright requires downloading browsers for each runner. This can add 30-90 seconds per job. Cache the browsers to avoid this:

- name: Cache Playwright browsers
  uses: actions/cache@v4
  id: playwright-cache
  with:
    path: ~/.cache/ms-playwright
    key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

- name: Install Playwright
  run: npx playwright install --with-deps chromium
  # Even with cache hit, --with-deps installs system dependencies
  # Use conditional to skip browser download when cached:

- name: Install browsers (if not cached)
  if: steps.playwright-cache.outputs.cache-hit != 'true'
  run: npx playwright install chromium

- name: Install system dependencies
  run: npx playwright install-deps chromium

For CI environments where you control the base image, consider using the official Playwright Docker image, which has browsers pre-installed:

    container:
      image: mcr.microsoft.com/playwright:v1.44.0-jammy
      options: --user 1001

Merging HTML and Blob Reports

Each shard produces a blob-report directory containing raw test result data. Playwright provides a built-in merge command to combine these into a single HTML report.

Add a merge job that runs after all shards complete:

  merge-reports:
    name: Merge Reports
    needs: test
    runs-on: ubuntu-latest
    if: always()

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Download all blob reports
        uses: actions/download-artifact@v4
        with:
          pattern: blob-report-*
          merge-multiple: true
          path: all-blob-reports

      - name: Merge into HTML report
        run: |
          npx playwright merge-reports \
            --reporter=html \
            --output=playwright-report \
            ./all-blob-reports

      - name: Upload merged HTML report
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report
          retention-days: 14

      - name: Upload merged report to GitHub Pages (optional)
        if: github.ref == 'refs/heads/main'
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./playwright-report

The merge-reports command understands Playwright's blob format and correctly aggregates test results, retry information, timing data, and screenshots from all shards.

Configuring Blob Reports in playwright.config.ts

By default, Playwright doesn't generate blob reports. You need to configure this explicitly:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,

  reporter: process.env.CI
    ? [
        ['blob'],           // For merging across shards
        ['github'],         // Inline annotations in PR
        ['list'],           // Console output per shard
      ]
    : [['html', { open: 'never' }]],

  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

Note: workers: 1 in CI is intentional here. When sharding, you're already parallelizing across machines — having multiple workers per machine for E2E tests can cause resource contention and flakiness. Use the shard count to control parallelism, not worker count.

Combining Coverage from Playwright Shards

Playwright's code coverage requires instrumentation. The setup differs depending on whether you're using Istanbul or V8:

With @playwright/test and Istanbul

// playwright.config.ts
export default defineConfig({
  use: {
    // Coverage requires a coverage-enabled build
    coverage: true,
  },
});
// tests/coverage-setup.ts
import { test as base } from '@playwright/test';
import { collectCoverage } from './coverage-utils';

export const test = base.extend({
  page: async ({ page }, use) => {
    await page.coverage.startJSCoverage();
    await use(page);
    const coverage = await page.coverage.stopJSCoverage();
    await collectCoverage(coverage); // Write to shard-specific file
  },
});

Merging Coverage Files

# Each shard writes to coverage/shard-{N}/coverage-final.json
# Merge with nyc or c8

# Using nyc
npx nyc merge coverage/shard-*/coverage-final.json merged-coverage.json
npx nyc report --reporter=lcov --reporter=text --temp-dir=. --report-dir=coverage

# Using c8
npx c8 merge coverage/shard-*/.coverage.json

In practice, most teams running Playwright for E2E tests measure coverage separately from their unit/integration test suites and don't merge Playwright coverage with Jest/Vitest coverage. The tooling for cross-framework coverage merging is immature. Consider tracking E2E coverage independently.

Playwright Service for Cloud Execution

Microsoft offers Playwright Testing (preview), a managed service that provides:

  • Cloud browsers on Azure infrastructure
  • No browser installation required in CI
  • Faster test execution via pre-warmed browsers
  • Built-in parallelism without managing matrix jobs
// playwright.config.ts (with service)
export default defineConfig({
  use: {
    connectOptions: process.env.PLAYWRIGHT_SERVICE_URL
      ? {
          wsEndpoint: process.env.PLAYWRIGHT_SERVICE_URL,
          headers: {
            'x-mpt-access-key': process.env.PLAYWRIGHT_SERVICE_ACCESS_TOKEN!,
          },
        }
      : undefined,
  },
});
# With Playwright service, no matrix needed
- name: Run Playwright tests
  run: npx playwright test --workers=20  # 20 parallel connections to service
  env:
    PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
    PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}

Cost vs. Speed Tradeoffs

Here's a realistic comparison for a suite of 200 E2E tests averaging 8 seconds each (total: ~27 minutes sequential):

Approach Wall Clock Runner Cost Monthly (50 PRs) Notes
Sequential (1 runner) 27 min 27 runner-min 22.5 hours Free tier covers this
3 shards 10 min 30 runner-min 25 hours 2.7x faster, similar cost
5 shards 6 min 30 runner-min 25 hours 4.5x faster
10 shards 4 min 40 runner-min 33 hours 6.75x faster, 47% more cost
Playwright service (20 workers) 2.5 min service fees ~$40-80/month Fastest, fixed predictable cost

The sweet spot for most teams is 5-8 shards. Beyond that, the overhead per shard (checkout, npm ci, browser install) starts to dominate and the speedup per additional shard diminishes.

For large organizations with hundreds of E2E tests running on every PR, the managed service often pays for itself in developer productivity even at $80/month.

Handling Flaky Tests in Sharded Runs

E2E tests are inherently more flaky than unit tests. Sharding amplifies this because flaky tests appear more frequently per unit time. Add retries:

// playwright.config.ts
export default defineConfig({
  retries: process.env.CI ? 2 : 0,  // Retry failed tests up to 2 times
  use: {
    trace: 'on-first-retry',  // Capture trace on first retry for debugging
  },
});

Track flakiness systematically. HelpMeTest monitors test reliability over time, so you can identify which tests are consistently flaky and fix them before they become a CI reliability problem.

Selective Sharding: Only Run Affected Tests

For large suites, you can reduce CI time further by only running tests affected by the current change:

# Run only tests related to changed files
npx playwright test --grep @checkout  # Tag-based selection

# Or use Playwright's project filtering
npx playwright test --project=chromium tests/checkout/

Combined with sharding:

- name: Run affected Playwright tests
  run: |
    # Determine which test directories were affected
    CHANGED=$(git diff --name-only origin/main | grep 'src/' | \
      sed 's|src/||' | cut -d'/' -f1 | sort -u)

    # Run corresponding test directories, sharded
    npx playwright test \
      --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} \
      $(echo $CHANGED | xargs -I{} echo "tests/{}/")

A Complete Production-Ready Setup

Putting everything together for a real project:

name: E2E Tests

on:
  push:
    branches: [main, develop]
  pull_request:

concurrency:
  group: e2e-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    name: E2E Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shardIndex: [1, 2, 3, 4, 5, 6]
        shardTotal: [6]

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Cache Playwright
        uses: actions/cache@v4
        id: playwright-cache
        with:
          path: ~/.cache/ms-playwright
          key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

      - name: Install Playwright
        run: npx playwright install --with-deps chromium firefox

      - name: Start app
        run: npm run build && npm run start &
        env:
          NODE_ENV: test

      - name: Wait for app
        run: npx wait-on http://localhost:3000 --timeout 60000

      - name: Run tests
        run: |
          npx playwright test \
            --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} \
            --reporter=blob,github
        env:
          BASE_URL: http://localhost:3000

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: blob-report-${{ matrix.shardIndex }}
          path: blob-report
          retention-days: 3

  merge-reports:
    needs: test
    runs-on: ubuntu-latest
    if: always()
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          cache: 'npm'
      - run: npm ci

      - uses: actions/download-artifact@v4
        with:
          pattern: blob-report-*
          merge-multiple: true
          path: all-blob-reports

      - run: npx playwright merge-reports --reporter=html ./all-blob-reports

      - uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report
          retention-days: 30

Summary

Playwright sharding with GitHub Actions is straightforward to set up and delivers substantial speedups for E2E suites. The key decisions:

  1. Shard count: Start with 5-6 shards. Measure time per shard and add more if the slowest shard is still too slow.
  2. Browser caching: Always cache ~/.cache/ms-playwright — this saves 30-90 seconds per job.
  3. Blob reports: Required for merging. Configure them explicitly in playwright.config.ts.
  4. Retries: Set retries: 2 in CI to handle flaky tests without blocking PRs.
  5. fail-fast: false: Always. You need to see all failures, not just the first.

For suites over 500 tests, evaluate the Playwright managed service. The infrastructure overhead of managing shards across many CI jobs often costs more in engineering time than the service subscription.

Read more

Start now free