The fintech client called me with a problem: their 1,200-test Playwright suite took 47 minutes per CI run. PRs queued up. Devs went for coffee while waiting. Roughly $4K/month in CI compute, plus the human cost of context-switching while waiting.
Six weeks later the same suite ran in 8 minutes. Same tests, same coverage. The trick was sharding plus a few smaller optimizations. Here's the exact strategy.
Table of Contents
- Why "just cut tests" is the wrong first response
- Sharding 101: parallelism across CI runners
- Playwright's built-in --shard flag
- Distributing tests evenly across shards
- Workers within a shard: the second axis of parallelism
- Per-test optimizations that compound
- The full GitHub Actions config that runs in 8 minutes
- Cost tradeoffs (more shards = more compute)
- FAQs
Why "Just Cut Tests" Is the Wrong First Response
It's tempting. 1,200 tests is a lot. Surely some of them are redundant?
Auditing for cuts is a separate exercise (see my bloated suite audit post). It's worth doing — but it's a months-long project that requires careful judgment. Sharding is a 2-day project that doesn't lose any coverage.
Do sharding first. Then, once your CI is fast, do the audit at your leisure.
Sharding 101: Parallelism Across CI Runners
One CI runner, 1,200 tests, 8 seconds per test = 9,600 seconds = 160 minutes serially. Even with parallel workers within one runner (4 workers), you're at 40 minutes.
Sharding splits the test suite across multiple CI runners. Run shard 1 of 4 on runner A, shard 2 on runner B, etc. Each shard has 300 tests. With 4 workers per runner, each shard takes ~10 minutes. Total wall time: 10 minutes (the slowest shard).
The math is linear: N runners ≈ N× speedup, minus overhead.
Playwright's Built-In --shard Flag
Playwright has sharding built in. Two flags:
npx playwright test --shard=1/4 # First quarter of tests
npx playwright test --shard=2/4 # Second quarter
npx playwright test --shard=3/4 # Third quarter
npx playwright test --shard=4/4 # Last quarter
Each shard runs a deterministic subset based on test file hashing. Run all four in parallel (different runners) and you get full coverage in roughly 1/4 the wall time.
Distributing Tests Evenly Across Shards
Default sharding is by test file. If your file sizes are uneven (one file has 50 tests, another has 5), shard run-time will be uneven.
Two ways to balance:
Option A: Pre-compute shards based on historical timing
Track per-test duration in CI artifacts. Use a balancing tool (playwright-test-balanced-sharding on npm, or write your own) to assign tests to shards based on time, not file count.
# Run with custom shard plan
npx playwright test --shard-plan=./shard-plan.json --shard=1/4
The plan file is generated from previous timing data. Total wall time becomes the average shard time, not the slowest.
Option B: Use --workers with adaptive shard balancing
Simpler: increase workers per shard so the slowest shard catches up. If shard 3 has heavier tests, give it more workers. Trickier to set up but no external tooling.
For most teams, default sharding gets you to within 20% of optimal. Only optimize further if your wall time is unacceptable.
Workers Within a Shard: The Second Axis of Parallelism
Each shard runs on one CI runner. Within that runner, Playwright spawns worker processes — each worker runs tests in its own browser context.
// playwright.config.ts
export default defineConfig({
fullyParallel: true,
workers: process.env.CI ? 4 : undefined,
});
Match workers to runner CPU. GitHub Actions standard runners have 2 vCPUs — workers = 2. Larger runners (paid) have more — workers = vCPUs - 1 (one for system overhead).
Combined with sharding: 4 shards × 4 workers each = 16 concurrent test executions. 1,200 tests ÷ 16 ≈ 75 tests per concurrent slot. At 8 seconds per test, ~10 minutes wall time. That's where the 8-minute number came from on the fintech client.
Per-Test Optimizations That Compound
After sharding, look at individual test speed. Five wins that compound:
1. Reuse browser contexts where safe
Tests that don't mutate global state can share a context. Saves the 100–200ms cold-start per test.
test.describe.configure({ mode: 'serial' }); // share context within file
2. Skip browser engines you don't need
Most teams don't need to run every test on Chromium AND Firefox AND WebKit. Run critical paths cross-browser; run the rest on Chromium only.
projects: [
{ name: 'chromium', testMatch: /.*\.spec\.ts/ },
{ name: 'firefox', testMatch: /critical-paths\/.*\.spec\.ts/ },
{ name: 'webkit', testMatch: /critical-paths\/.*\.spec\.ts/ },
]
3. Disable trace and video on green tests
use: {
trace: 'retain-on-failure',
video: 'retain-on-failure',
screenshot: 'only-on-failure',
}
Saves the per-test overhead of recording. Only failures pay the cost.
4. Reuse auth state via storageState
Logging in for every test wastes 4 seconds × 1,200 tests = 80 minutes of compute. Use the multi-user auth pattern from my auth state post.
5. Mock slow third-party APIs
If your tests call Stripe, Google Maps, etc. for setup data, mock them in test mode. See my mocking patterns post.
The Full GitHub Actions Config That Runs in 8 Minutes
name: E2E Tests
on: pull_request
jobs:
test:
timeout-minutes: 15
runs-on: ubuntu-latest
strategy:
fail-fast: false
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
- run: npx playwright install --with-deps chromium
- name: Run shard ${{ matrix.shard }}/4
run: npx playwright test --shard=${{ matrix.shard }}/4
env:
E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }}
- name: Upload report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report-shard-${{ matrix.shard }}
path: playwright-report
retention-days: 7
- name: Upload blob report
if: always()
uses: actions/upload-artifact@v4
with:
name: blob-report-${{ matrix.shard }}
path: blob-report
retention-days: 1
merge-reports:
needs: test
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- uses: actions/download-artifact@v4
with:
path: blob-reports
pattern: blob-report-*
merge-multiple: true
- run: npx playwright merge-reports --reporter html ./blob-reports
- uses: actions/upload-artifact@v4
with:
name: html-report-merged
path: playwright-report
retention-days: 7
Four shards run in parallel. The merge-reports job at the end combines them into one HTML report so you don't have to dig through four artifacts.
Cost Tradeoffs (More Shards = More Compute)
4 shards × 8 minutes ≈ 32 runner-minutes per PR. The serial version was 47 minutes — so you're paying ~70% as much CI compute for a 6× faster wall time.
If you go to 8 shards, wall time approaches 4–5 minutes but compute cost goes to ~40 minutes. At some point you stop saving wall time (sharding overhead dominates) and just spend more.
For most teams, 4 shards is the sweet spot. If your suite is <500 tests, 2 shards is enough. If it's >3,000 tests, 8+ shards might pay off.
FAQs
What if my CI provider charges per minute, not per runner?
Sharding still saves wall time but might cost more. Calculate cost per shard configuration and pick what your finance team will tolerate.
Does this work with self-hosted runners?
Yes. Just make sure you have enough self-hosted runners for the matrix size. 4-shard matrix needs 4 concurrent runners.
How do I keep shards balanced over time as tests are added?
Re-generate the shard plan periodically (weekly cron). New tests get added to the lightest shard automatically.
Should I shard by file or by test?
Playwright's default is by file. By-test sharding is fancier but rarely needed unless individual files have wildly varying test counts.
What about retries — do they add to wall time?
Only if you have retries: 1+ set and tests actually retry. Each retry adds the test's run time. Aim for 0 retries on a stable suite.
Does sharding work for Playwright Component Testing?
Yes. Same flags. CT tests are usually faster than E2E so sharding has less to gain there.
Can I shard within one runner using multiple workers?
Workers and sharding are different axes. Workers = parallelism within a runner. Sharding = parallelism across runners. Use both.
What about dependent tests that must run serially?
Use test.describe.configure({ mode: 'serial' }). Tests within that describe block run on one worker; other tests still parallelize.
How do I see merged results across shards?
Playwright's blob reports + merge-reports command. The CI config above includes the merge step.
Does this work with retries-on-failure for flaky tests?
Yes, but flaky tests inflate shard time and hide real problems. Fix flakiness instead of relying on retries — see my race conditions post.
Wrap-Up
Sharding is the highest-leverage performance change for any Playwright suite over ~300 tests. The setup is a day; the speedup is permanent. Combined with worker parallelism, browser-skipping, and auth state reuse, the fintech client's 47-minute suite became an 8-minute suite without losing coverage.
If your team has a slow CI suite and wants help implementing sharding + the supporting optimizations, that's exactly the kind of work I do in framework engagements. Or book a free call.
Related reading:
Tayyab Akmal
AI & QA Automation Engineer
6 years of catching critical bugs in fintech, e-commerce, and SaaS — then building the Playwright and Selenium automation that prevents them from shipping again.