Parallel Execution and Sharding
February 13, 2026 ยท View on GitHub
When to use: Speeding up test suites by running tests concurrently within a single machine (parallelism) or across multiple machines (sharding). Essential once your suite exceeds 5 minutes.
Quick Reference
# Workers (parallelism within one machine)
npx playwright test --workers=4 # fixed worker count
npx playwright test --workers=50% # percentage of CPU cores
# Sharding (splitting across machines)
npx playwright test --shard=1/4 # run first quarter
npx playwright test --shard=2/4 # run second quarter
# Merging shard results
npx playwright merge-reports ./blob-report # merge to default HTML
npx playwright merge-reports --reporter=html,json ./blob-report # multiple formats
# Fully parallel mode
npx playwright test --fully-parallel # override config for this run
Patterns
Pattern 1: Configuring Workers
Use when: Controlling how many tests run simultaneously on one machine. Avoid when: You only have 1-2 tests (parallelism has no effect).
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
// fullyParallel: true means tests WITHIN a file also run in parallel.
// Without it, only files run in parallel (tests within a file are serial).
fullyParallel: true,
// Workers: how many parallel processes
// - undefined: auto-detect (half CPU cores, capped at a reasonable number)
// - number: fixed count
// - string percentage: '50%' of CPU cores
workers: process.env.CI ? '50%' : undefined,
});
What fullyParallel actually controls:
| Setting | Files run in parallel | Tests within a file run in parallel |
|---|---|---|
fullyParallel: false (default) | Yes | No -- serial within each file |
fullyParallel: true | Yes | Yes -- every test is independent |
Per-file override when one file needs serial execution:
// tests/onboarding.spec.ts
import { test, expect } from '@playwright/test';
// This file's tests run serially even with fullyParallel: true in config
test.describe.configure({ mode: 'serial' });
test('step 1: enter company name', async ({ page }) => {
// ...
});
test('step 2: choose plan', async ({ page }) => {
// ...
});
Pattern 2: Sharding Across CI Machines
Use when: Suite is too slow for a single machine even with maximum workers. Split work across N separate CI jobs. Avoid when: Suite runs under 5 minutes on one machine.
Sharding splits the test file list into N equal groups. Each shard runs one group.
# Machine 1 Machine 2 Machine 3 Machine 4
--shard=1/4 --shard=2/4 --shard=3/4 --shard=4/4
Config for sharded runs -- use blob reporter:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
fullyParallel: true,
workers: process.env.CI ? '50%' : undefined,
// Blob reporter outputs a binary file that can be merged later.
// In non-CI, use HTML for local viewing.
reporter: process.env.CI
? [['blob'], ['github']]
: [['html', { open: 'on-failure' }]],
});
Pattern 3: Merging Blob Reports from Shards
Use when: You sharded your tests and need a single unified report. Avoid when: No sharding -- the regular HTML reporter works directly.
Each shard produces a .zip file in blob-report/. After all shards complete, merge them:
# Download all blob-report/ directories into one folder, then:
npx playwright merge-reports --reporter=html ./all-blob-reports
# Multiple output formats
npx playwright merge-reports --reporter=html,json,junit ./all-blob-reports
# Custom output directory
PLAYWRIGHT_HTML_REPORT=merged-report npx playwright merge-reports --reporter=html ./all-blob-reports
GitHub Actions example (merge job):
merge-reports:
if: ${{ !cancelled() }}
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- uses: actions/download-artifact@v4
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- run: npx playwright merge-reports --reporter=html ./all-blob-reports
- uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 14
Pattern 4: Worker-Scoped Fixtures for Shared Resources
Use when: Parallel workers each need an expensive resource (database connection, auth token) that should be created once per worker, not once per test. Avoid when: The resource is cheap to create -- use a regular test-scoped fixture.
// fixtures.ts
import { test as base } from '@playwright/test';
type WorkerFixtures = {
dbConnection: DatabaseClient;
workerAuthToken: string;
};
export const test = base.extend<{}, WorkerFixtures>({
// Created once per worker process, shared across all tests in that worker
dbConnection: [async ({}, use) => {
const db = await DatabaseClient.connect(process.env.DB_URL!);
await use(db);
await db.disconnect();
}, { scope: 'worker' }],
workerAuthToken: [async ({}, use, workerInfo) => {
// Each worker gets a unique user to avoid test interference
const response = await fetch(`${process.env.API_URL}/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: `worker-user-${workerInfo.workerIndex}`,
password: process.env.TEST_PASSWORD,
}),
});
const { token } = await response.json();
await use(token);
}, { scope: 'worker' }],
});
export { expect } from '@playwright/test';
Pattern 5: Test Isolation for Safe Parallelism
Use when: Preparing tests to run in parallel without interference. Avoid when: Never -- isolation is always required for reliable parallel execution.
The golden rule: Each test must create its own state and clean up after itself. No test should depend on or modify state that another test uses.
// BAD: Tests share a hardcoded user -- parallel runs collide
test('update profile', async ({ page }) => {
await page.goto('/users/shared-user/profile');
await page.getByLabel('Name').fill('New Name');
await page.getByRole('button', { name: 'Save' }).click();
// Another parallel test also editing "shared-user" -- race condition!
});
// GOOD: Each test creates its own user
test('update profile', async ({ page, request }) => {
// Create a unique user for this test
const res = await request.post('/api/test/users', {
data: { name: `user-${Date.now()}`, email: `${Date.now()}@test.com` },
});
const user = await res.json();
await page.goto(`/users/${user.id}/profile`);
await page.getByLabel('Name').fill('Updated Name');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByLabel('Name')).toHaveValue('Updated Name');
// Cleanup
await request.delete(`/api/test/users/${user.id}`);
});
Using workerInfo and testInfo for unique identifiers:
import { test, expect } from '@playwright/test';
test('create order', async ({ page }, testInfo) => {
const uniqueId = `order-${testInfo.workerIndex}-${Date.now()}`;
// Use uniqueId for any data this test creates
await page.goto(`/orders/new?ref=${uniqueId}`);
// ...
});
Pattern 6: Dynamic Shard Count Based on Test Count
Use when: You want to automatically adjust shard count based on how many tests exist, rather than hardcoding. Avoid when: Your test count is stable and a fixed shard count works well.
# .github/workflows/playwright.yml -- dynamic shard calculation
jobs:
determine-shards:
runs-on: ubuntu-latest
outputs:
shard-count: ${{ steps.calc.outputs.count }}
shard-matrix: ${{ steps.calc.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- id: calc
run: |
TEST_COUNT=$(npx playwright test --list --reporter=json 2>/dev/null | node -e "
const data = require('fs').readFileSync('/dev/stdin', 'utf8');
const parsed = JSON.parse(data);
console.log(parsed.suites?.reduce((acc, s) => acc + (s.specs?.length || 0), 0) || 0);
")
# 1 shard per 20 tests, minimum 1, maximum 8
SHARDS=$(( (TEST_COUNT + 19) / 20 ))
SHARDS=$(( SHARDS > 8 ? 8 : SHARDS ))
SHARDS=$(( SHARDS < 1 ? 1 : SHARDS ))
# Build matrix array: ["1/N", "2/N", ...]
MATRIX="["
for i in $(seq 1 $SHARDS); do
[ $i -gt 1 ] && MATRIX+=","
MATRIX+="\"$i/$SHARDS\""
done
MATRIX+="]"
echo "count=$SHARDS" >> $GITHUB_OUTPUT
echo "matrix=$MATRIX" >> $GITHUB_OUTPUT
test:
needs: determine-shards
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: ${{ fromJson(needs.determine-shards.outputs.shard-matrix) }}
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
- run: npx playwright test --shard=${{ matrix.shard }}
Decision Guide
| Scenario | Workers | Shards | Why |
|---|---|---|---|
| < 50 tests, < 5 min | Auto (default) | None | No optimization needed |
| 50-200 tests, 5-15 min | '50%' in CI | 2-4 shards | Balance speed and CI cost |
| 200+ tests, > 15 min | '50%' in CI | 4-8 shards | Keep feedback under 10 min |
| Flaky tests due to resource contention | Reduce: workers: 2 | Keep current | Fewer workers = less CPU/memory pressure |
| Tests modify shared database | workers: 1 or isolate per worker | Still useful | Sharding splits files; workers run them |
| CI has limited CPU/RAM | workers: 1 or '25%' | More shards | Compensate fewer workers with more machines |
| Question | workers (in-process) | --shard (across machines) |
|---|---|---|
| What does it split? | Tests across CPU cores on one machine | Test files across separate CI jobs |
| Controlled by? | playwright.config.ts or --workers CLI | --shard=X/Y CLI flag |
| Shares memory? | Yes (same machine) | No (separate machines) |
| Report merging needed? | No (single process) | Yes (merge-reports) |
| Cost | Free (same machine) | More CI minutes (more machines) |
Anti-Patterns
| Anti-Pattern | Problem | Do This Instead |
|---|---|---|
fullyParallel: false with no reason | Tests within files run serially; slow suite | Set fullyParallel: true unless specific tests need serial |
workers: 1 in CI "to be safe" | Negates parallelism entirely | Fix isolation issues; use workers: '50%' |
| Tests sharing a hardcoded user account | Race conditions when parallel -- both tests modify same data | Each test creates unique data via API or fixture |
--shard=1/4 without blob reporter | Each shard produces its own HTML report; no merged view | Configure reporter: [['blob']] for sharded CI runs |
| Sharding with 3 tests | Overhead of shard setup exceeds time saved | Only shard when suite exceeds 5 minutes |
test.describe.serial() everywhere | Kills parallelism, creates hidden dependencies | Use only when tests genuinely depend on prior state |
| Worker count higher than CPU cores | Context switching overhead; slower, not faster | Use '50%' or let Playwright auto-detect |
Not using fail-fast: false in CI matrix | One shard failure cancels others; incomplete results | Always set fail-fast: false for sharded strategies |
Troubleshooting
Tests pass alone but fail when run together
Cause: Shared state between tests -- database rows, cookies, global variables, file system.
Fix: Isolate each test. Use unique data per test:
test('create order', async ({ page, request }, testInfo) => {
// Unique product per test -- no collision with parallel tests
const product = await request.post('/api/test/products', {
data: { name: `Widget-${testInfo.workerIndex}-${Date.now()}` },
});
// ...
});
Shard produces no tests: "No tests found"
Cause: Shard count exceeds the number of test files. A shard gets zero files.
Fix: Reduce shard count to at most the number of test files:
# If you have 10 test files, max 10 shards
npx playwright test --shard=1/10 # OK
npx playwright test --shard=1/20 # Some shards will be empty
Merged report missing some test results
Cause: Blob report files from a shard were not downloaded or were overwritten due to name collision.
Fix: Give each shard's artifact a unique name:
# Each shard
- uses: actions/upload-artifact@v4
with:
name: blob-report-${{ strategy.job-index }} # unique per shard
path: blob-report/
# Merge step
- uses: actions/download-artifact@v4
with:
pattern: blob-report-*
merge-multiple: true
path: all-blob-reports
Worker-scoped fixture not shared -- recreated per test
Cause: Missing { scope: 'worker' } option, or the fixture depends on a test-scoped fixture.
Fix: Ensure the fixture uses worker scope and only depends on worker-scoped fixtures:
export const test = base.extend<{}, { sharedResource: Resource }>({
sharedResource: [async ({}, use) => {
const resource = await Resource.create();
await use(resource);
await resource.destroy();
}, { scope: 'worker' }], // Don't forget this
});
Tests are slower with more workers
Cause: Machine is CPU- or memory-bound. More workers cause thrashing.
Fix: Reduce workers until you find the sweet spot:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
workers: process.env.CI ? 2 : undefined, // Start low, increase if stable
});
Related
- ci/ci-github-actions.md -- sharded GitHub Actions workflow
- ci/ci-gitlab.md -- GitLab
parallel:keyword with sharding - ci/ci-other.md -- sharding on CircleCI, Azure DevOps, Jenkins
- ci/reporting-and-artifacts.md -- blob reporter and merge-reports
- core/fixtures-and-hooks.md -- worker-scoped fixtures
- core/test-organization.md -- parallel vs serial execution