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/3Playwright 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: 1The 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 chromiumFor 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 1001Merging 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-reportThe 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.jsonIn 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: 30Summary
Playwright sharding with GitHub Actions is straightforward to set up and delivers substantial speedups for E2E suites. The key decisions:
- Shard count: Start with 5-6 shards. Measure time per shard and add more if the slowest shard is still too slow.
- Browser caching: Always cache
~/.cache/ms-playwright— this saves 30-90 seconds per job. - Blob reports: Required for merging. Configure them explicitly in
playwright.config.ts. - Retries: Set
retries: 2in CI to handle flaky tests without blocking PRs. - 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.