Playwright architecture, selector reliability, and advanced interaction patterns.

Running Playwright Tests in GitHub Actions with Sharding

A suite that takes twelve minutes on one runner takes about four when split across three runners that work at the same time, because the merge is gated by the slowest job, not the sum of all jobs. GitHub Actions makes this split a matrix of identical jobs, and Playwright slices the test set with --shard=index/total. The complication is the report: three jobs produce three partial results, so without a merge step you get three inconclusive runs instead of one verdict. The blob reporter solves it — each shard writes a machine-readable blob, a final job downloads them all and runs merge-reports to fold them into a single HTML report and a single exit code. This page builds the full workflow step by step.

Sharded matrix jobs feeding a single merge job Three matrix jobs each run one shard and upload a blob report artifact, then a dependent merge job downloads all blobs and produces one combined HTML report. job: shard 1/3 blob-report-1 job: shard 2/3 blob-report-2 job: shard 3/3 blob-report-3 download all artifacts merge-reports one report
Matrix jobs run shards in parallel and each emits a blob; the dependent merge job is the only place a single pass-or-fail result exists.

Why a plain matrix is not enough

A GitHub Actions matrix already runs jobs in parallel, so it is tempting to think the only missing piece is passing a shard index. The problem is the result. Each shard exits with its own status and writes its own report, and the pull request check then shows three separate green-or-red marks with no combined HTML report to open. Worse, the standard list or HTML reporter is not designed to be merged after the fact. The blob reporter exists for exactly this: it serializes the full result of a shard — every test, attachment, and trace pointer — into a format that merge-reports can recombine losslessly into the report you would have gotten from a single run.

The distinction matters most on a failing run. With three unmerged shards, a reviewer has to open each job log, guess which slice held the failing spec, and download three artifact bundles to find one trace. With a merge job, the pull request carries one required check and one artifact, and the reporters and artifacts you already rely on behave exactly as they do locally. The table below sets the three options side by side.

Comparing one runner, a plain matrix, and a matrix with a merge job A four-row comparison table showing wall clock, pull request verdicts, merged report availability, and trace location for three CI arrangements. criterion one runner plain matrix matrix + merge wall clock 12 min about 4 min about 5 min PR verdicts one three one merged report yes no yes traces in report all in one split by job all in one
Only the merged arrangement buys parallel wall-clock time without giving up the single verdict and the single artifact a reviewer needs.

Configure the blob reporter

Switch the reporter to blob in CI while keeping a readable reporter locally. The blob reporter writes to blob-report/ by default and embeds the shard index in the file name so merging never collides.

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

export default defineConfig({
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  // 'blob' in CI is mergeable; 'list' locally stays human-readable.
  reporter: process.env.CI ? 'blob' : 'list',
  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});

This config is the same one described in Playwright Config & Fixtures; the only CI-specific addition is the conditional reporter. Retries stay on in CI because a merged report shows a retried test as flaky rather than failed, which is the signal you want when you configure retries and timeouts for stable CI.

Minimal reproducible example

Sharding assigns whole spec files to runners, so any test that silently depends on a fixture another file created will pass on one runner and fail the moment the two files land on different shards. That is the single most common reason a suite goes green locally and red in a sharded pipeline. The test below is written so it cannot break that way: it builds every record it needs and names them per worker.

import { test, expect } from '@playwright/test';

// This file runs on whichever shard owns it. There is no ordering guarantee
// between shards, so the test must create everything it depends on.
test('a report row appears for a newly created project', async ({ page }, testInfo) => {
  // Worker index plus timestamp keeps names unique across every parallel runner.
  const projectName = `rollout-${testInfo.workerIndex}-${Date.now()}`;

  await page.goto('/projects/new');
  await page.getByLabel('Project name').fill(projectName);
  await page.getByRole('button', { name: 'Create project' }).click();

  // Web-first assertion: retries until the app has committed the write.
  await expect(page.getByRole('heading', { name: projectName })).toBeVisible();

  await page.goto('/reports');
  // Scope the row lookup to the name this test owns, never to "the first row".
  const row = page.getByRole('row').filter({ hasText: projectName });
  await expect(row).toHaveCount(1);
});

Two habits make a spec shard-safe: derive unique data from testInfo.workerIndex rather than a shared constant, and never assert on positional locators such as "the first row", which only hold when the whole suite ran in a known order. Expensive shared setup that genuinely must be reused belongs in a worker-scoped fixture, which each shard builds independently.

The workflow file

The workflow below defines two jobs. The test job is a matrix over shard values that each run one slice and upload a blob artifact. The merge job depends on test, downloads every blob, and produces the combined report. This is the one allowed non-TypeScript code block.

name: Playwright Tests
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    timeout-minutes: 30
    runs-on: ubuntu-latest
    strategy:
      # Keep all shards running even if one fails, so you see the full picture.
      fail-fast: false
      matrix:
        shard: [1, 2, 3]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - name: Install dependencies
        run: npm ci
      # Cache browsers keyed on the resolved Playwright version.
      - name: Get Playwright version
        id: pw
        run: echo "version=$(npm ls @playwright/test --depth=0 --json | npx --yes json @playwright/test.version)" >> "$GITHUB_OUTPUT"
      - name: Cache browsers
        id: cache
        uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          key: pw-${{ runner.os }}-${{ steps.pw.outputs.version }}
      - name: Install browsers
        if: steps.cache.outputs.cache-hit != 'true'
        run: npx playwright install --with-deps
      - name: Install OS deps on cache hit
        if: steps.cache.outputs.cache-hit == 'true'
        run: npx playwright install-deps
      # The shard flag slices the suite: index/total.
      - name: Run shard ${{ matrix.shard }}
        run: npx playwright test --shard=${{ matrix.shard }}/${{ strategy.job-total }}
      # Each shard uploads its blob under a unique name.
      - name: Upload blob report
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v4
        with:
          name: blob-report-${{ matrix.shard }}
          path: blob-report/
          retention-days: 7

  merge:
    if: ${{ !cancelled() }}
    needs: [test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      # Pull every shard's blob into one folder.
      - name: Download blob reports
        uses: actions/download-artifact@v4
        with:
          path: all-blob-reports
          pattern: blob-report-*
          merge-multiple: true
      # Fold the blobs into one HTML report and a single exit code.
      - name: Merge into HTML report
        run: npx playwright merge-reports --reporter=html ./all-blob-reports
      - name: Upload merged report
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

Reading the schedule as a timeline explains where the saving comes from and where it stops. Each shard pays the same fixed cost — checkout, npm ci, browser restore — before it runs a single test, and the merge job pays it once more. The diagram below plots a twelve-minute suite against three shards plus the merge.

Wall-clock timeline for one runner versus three shards and a merge A timeline comparing a single twelve-minute run with three parallel shard bars of roughly four minutes each followed by a short merge job. one runner 12 min wall clock 3 shards shard 1/3 3.8 min shard 2/3 4.2 min shard 3/3 3.6 min merge merge starts after the slowest shard 0 4 min 8 min 12 min
The pipeline finishes when the slowest shard plus the merge finishes, so an unbalanced shard, not the total test count, sets the feedback time.

Step-by-step fix

  1. Make every spec file self-sufficient. Create the data each test needs inside that test, derive unique names from testInfo.workerIndex, and drop positional assumptions, because sharding gives no ordering guarantee between files.
  2. Switch the reporter to blob in CI. Set reporter: process.env.CI ? 'blob' : 'list' so each shard writes a mergeable artifact while local runs stay readable.
  3. Define the shard matrix. Add strategy.matrix.shard: [1, 2, 3] with fail-fast: false so every shard runs to completion and reports its own failures.
  4. Cache browsers by Playwright version. Restore ~/.cache/ms-playwright keyed on the resolved version, install browsers only on a cache miss, and install just the OS deps on a hit.
  5. Pass the shard flag. Run npx playwright test --shard=${{ matrix.shard }}/${{ strategy.job-total }} so each job executes exactly one slice of the suite.
  6. Upload each blob with a unique name. Use actions/upload-artifact with name: blob-report-${{ matrix.shard }} and if: ${{ !cancelled() }} so reports survive failures.
  7. Add a dependent merge job. Declare needs: [test], download every blob-report-* artifact with merge-multiple: true, and run npx playwright merge-reports --reporter=html to produce one report and one verdict.

Fanning out over browsers as well as shards

Once the merge job exists, adding browsers costs nothing structural: give the matrix a second dimension over project and pass --project alongside --shard, so a three-shard, three-browser run becomes nine jobs that all feed the same merge step. The blob artifact name has to include both dimensions — blob-report-${{ matrix.project }}-${{ matrix.shard }} — or the second browser overwrites the first. Define the browsers as projects in the config so the flag has something to select.

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

export default defineConfig({
  reporter: process.env.CI ? 'blob' : 'list',
  use: { trace: 'on-first-retry' },
  // The matrix passes --project=<name>; each name maps to one browser engine.
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
});

Nine runners still finish in roughly the time of the slowest single shard, and the merged report groups results by project so an engine-specific failure is obvious at a glance. Engine differences worth knowing before you fan out are covered in Cross-Browser Execution. If restoring browsers starts dominating the per-job cost at that width, tighten the cache as described in caching Playwright browsers in CI.

Troubleshooting variants

Shards pass individually but the merge job is skipped

The merge job only runs when its dependency completes, and a failed shard with default settings can short-circuit the matrix. Set fail-fast: false on the matrix and if: ${{ !cancelled() }} on the merge job so the report is built even when a shard fails — a red report you can open beats no report at all. The state diagram below shows which paths still reach a verdict.

States a sharded run passes through on its way to one verdict A state diagram where running shards either fail or pass, both paths reach the merge job guarded by a not-cancelled condition, and the merge emits a red or green report. shards run a shard fails all shards pass merge job if: !cancelled() report: red report: green With fail-fast left on, a failing shard cancels its siblings and the merge job never reaches a verdict.
Both the failing and the passing path must lead into the merge job; only a cancelled matrix leaves the run without a single verdict.

Tests are unevenly distributed across shards

Playwright assigns whole test files to shards, so a single very large file lands entirely on one shard and skews timing. Split oversized spec files, or move long-running scenarios into their own files so the runner can balance them. Sharding distributes files, not individual tests within a file. Watch the per-shard durations in the merged report over a few runs: a shard that is consistently two minutes slower than its peers is holding one heavy file, and splitting that file recovers the difference immediately.

Traces are missing from the merged report

The blob artifact must include the attachments, which means trace has to be enabled in the config and the blob upload must point at the whole blob-report/ directory. Confirm trace: 'on-first-retry' is set and inspect failures in the Playwright Trace Viewer from the merged report.

One shard fails on every rerun but not locally

A shard that fails reproducibly in CI and passes on a laptop is usually running on a smaller machine with fewer cores, so tests that raced each other successfully at home now interleave differently. Reduce workers in the CI branch of the config before you start disabling tests, and follow the diagnosis path in detecting and fixing flaky Playwright tests rather than adding retries until the red goes away.

Verification

Open a pull request and confirm four things. First, the Actions run shows three parallel test jobs followed by one merge job, and the merged wall-clock time is close to a third of a single run. Second, download the playwright-report artifact and open index.html: it lists every test from all shards in one place, with traces attached to failures. Third, force a failure in one shard and confirm the merge job still produces a report marked red — proving the gate reports a single, correct verdict. Fourth, rerun the workflow unchanged and check that the browser cache hit, so the second run's per-job overhead drops to the install-deps step alone. The broader pipeline context lives in the CI/CD Integration guide, under Playwright Setup & Core Architecture.

Frequently Asked Questions

How do I choose the number of shards?

Pick a shard count that brings the slowest job under your tolerance for feedback time, then stop adding shards once per-job overhead — checkout, install, browser restore — eats the time you save. For most suites three to four shards is the sweet spot; beyond that the fixed startup cost of each runner dominates.

What does merge-reports actually do?

It reads the blob artifacts from every shard and recombines them losslessly into a single report in the format you request, such as HTML, with one overall exit code. It is the only step where the parallel jobs become one pass-or-fail result, so the pull request check reflects the whole suite rather than three separate slices.

Do I need the blob reporter, or can I merge HTML reports?

You need the blob reporter. The HTML reporter is a final rendering and is not designed to be recombined, whereas the blob reporter serializes the full structured result specifically so merge-reports can fold the shards together without losing tests, attachments, or traces.

Which job should the required status check point at?

Point the required check at the merge job, not at the matrix. The matrix jobs are intentionally allowed to finish red while the merge job still runs, so making them required would block a pull request on partial information. The merge job's exit code is the one that reflects every shard.

Can I shard across browsers and shards at the same time?

Yes. Add a second matrix dimension over project names, pass both --project and --shard to the run step, and include both values in the artifact name so the uploads stay distinct. Every job still writes a blob, and the single merge step folds all of them into one report grouped by project.

Back to overview