Sharding Playwright Tests Across GitHub Actions Runners
Playwright has native sharding, so the workflow is mostly plumbing β and the plumbing is where the time goes. Browser binaries downloaded four times, a report that only exists per shard, and a merge job that skips itself whenever a test fails: this is the configuration that avoids all three.
When to use this pattern
- A Playwright suite is the longest job in the pipeline and its files are independent.
- You already have a shard count in mind from measuring the suite, per parallelizing test suites in CI.
- You need a single readable report and per-failure traces, not four partial ones.
Prerequisites
What each shard has to set up
The per-shard overhead is the thing that decides whether adding a shard helps, and on Playwright it is dominated by one avoidable download.
Complete working example
# .github/workflows/e2e.yml
name: E2E
on: [pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false # one failing shard must not hide the others
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci --ignore-scripts
# Pin the cache to the exact Playwright version: a version bump ships new
# browser builds, and restoring the old ones produces confusing launch errors.
- id: pw
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
id: browsers
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }}
# On a hit, install only the OS packages; on a miss, download the browsers too.
- if: steps.browsers.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium
- if: steps.browsers.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Run shard ${{ matrix.shard }} of 4
run: npx playwright test --shard=${{ matrix.shard }}/4 --reporter=blob
env:
BASE_URL: ${{ vars.PREVIEW_URL }}
- uses: actions/upload-artifact@v4
if: always() # a failed shard's blob is the one you most want
with:
name: blob-${{ matrix.shard }}
path: blob-report
retention-days: 1
report:
needs: e2e
if: always() # merge even when a shard failed β otherwise no report exists
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci --ignore-scripts
- uses: actions/download-artifact@v4
with: { pattern: blob-*, path: blobs, merge-multiple: true }
- run: npx playwright merge-reports --reporter=html,github ./blobs
- uses: actions/upload-artifact@v4
with: { name: playwright-report, path: playwright-report, retention-days: 7 }// playwright.config.ts β worker count set explicitly for the runner, not the laptop.
import { defineConfig } from '@playwright/test';
export default defineConfig({
// shards Γ workers = concurrent browsers. 4 Γ 2 = 8 against one preview backend
// is usually fine; 4 Γ 4 on a 2-core runner is memory contention, not parallelism.
workers: process.env.CI ? 2 : undefined,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [['blob']] : [['list']],
use: {
baseURL: process.env.BASE_URL,
trace: 'retain-on-failure',
video: 'retain-on-failure',
},
});Step-by-step walkthrough
Cache keyed on the Playwright version, nothing else. Browser builds are tied to the package version, so the version is the complete cache key. Keying on the lockfile hash instead invalidates the browsers on every unrelated dependency change; keying on nothing invalidates never, and a version bump then launches a stale binary with an error that reads like a broken test.
install-deps on a hit, install --with-deps on a miss. The cache holds the browser binaries but not the system libraries they link against, and the runner image does not ship them all. Splitting the two paths keeps the warm case at four seconds instead of forty.
Blob is the only mergeable reporter. HTML and list reporters produce per-shard output that cannot be combined. Blob is an intermediate format designed for merge-reports, and switching to it is what makes the single-report goal reachable at all.
if: always() twice, for different reasons. On the upload, because a failing shardβs blob is the one containing the trace you need. On the merge job, because otherwise a single failure leaves you with four artifacts and no report β the exact situation the merge job exists to prevent.
Set workers explicitly. Playwright defaults to roughly one worker per core. Four shards each defaulting to two workers on a two-core runner is eight concurrent browsers against one preview backend, which produces timeouts that look like application faults. This interaction is covered further in running E2E tests against preview environments.
Verification
# 1. The browser cache is being hit β check a shard's log.
gh run view --log --job "e2e (2)" | grep -iE 'cache (restored|not found)'
# 2. All four blobs arrived and the merge consumed them.
gh run view --log --job report | grep -E 'Downloading|merge-reports|report' | head
# 3. The merged report covers the whole suite, not one shard.
gh run download -n playwright-report -D /tmp/report
jq '[.stats.expected, .stats.unexpected, .stats.flaky] | @tsv' /tmp/report/report.json 2>/dev/null \
|| grep -o 'Total.*tests' /tmp/report/index.html | head -1Expected total test count from the third command should match the serial runβs:
248 tests across 4 shards β the same number a single-shard run reportsIf the merged count is roughly a quarter of that, merge-multiple: true was omitted from the download and only one blob was read.
Getting the shard count right for this suite
Playwright suites have a characteristic that changes the arithmetic: the fixed per-shard cost is unusually high, because browsers and system dependencies must be present before a single test runs. Even fully cached, a shard does not start testing for roughly 30 seconds.
That sets a floor. If the whole suite runs in three minutes, four shards means each does about 45 seconds of testing on top of 30 seconds of setup β 40% overhead, for a wall-clock of 75 seconds against 180. Real, but eight shards would be worse rather than better: each would do 22 seconds of testing on 30 seconds of setup, and the wall-clock would stop improving while runner-minutes nearly doubled.
The practical rule for Playwright specifically is to stop when the slowest shardβs test time falls below its setup time. In most repositories that lands between three and six shards, and it is a lower ceiling than for unit tests precisely because of the browser install. Measure with the timings from the run summary rather than guessing, and re-check after any change to the browser matrix β adding Firefox and WebKit multiplies the setup cost per shard.
Common pitfalls
Caching node_modules and expecting browsers to come with it. They do not β the binaries live in ~/.cache/ms-playwright. Two caches, two keys.
Using the HTML reporter per shard. You end up with four independent reports and no way to combine them. Blob per shard, HTML once at the end.
Leaving retries high to hide flakiness. Sharding surfaces genuine test coupling, and three retries buries it again while tripling the cost of a bad shard. One retry absorbs transient network faults; anything more is a decision to stop seeing the problem.
Related
- Parallelizing Test Suites in CI β the parent guide covering shard mechanics and balancing.
- Merging Coverage Reports from Parallel Test Shards β the coverage half of the same merge problem.
- Running E2E Tests Against Preview Environments β what the sharded suite usually points at.
- Build Optimization & Caching Strategies β the section overview.
β Back to Parallelizing Test Suites in CI