Playwright Component Testing at Scale: Parallelization and Sharding
Playwright Component Testing has a startup cost that unit test frameworks don't: each worker spins up its own Vite dev server. With 2 workers, you have 2 Vite instances. With 8, you have 8. At small scale this is invisible. At 500+ component tests across a large design system or application, Vite startup overhead accumulates and your CI feedback loop stretches from minutes to tens of minutes.
Understanding how CT parallelism works — and where the bottlenecks are — is the prerequisite for optimizing it.
How Playwright CT Workers Work
When Playwright runs tests, it distributes test files across workers. Each worker is a separate Node.js process. In standard E2E tests, workers share nothing — they each get their own browser context.
In CT, each worker also gets its own Vite dev server on a unique port. The worker starts Vite, waits for it to be ready, then begins running its assigned test files. There is no Vite server sharing between workers. This is by design: isolated Vite instances mean no module cache cross-contamination between tests.
The practical consequence: spinning up 8 workers doesn't give you 8x the throughput. You're also paying for 8 Vite startups in parallel. On a fast machine, each Vite startup takes 1-3 seconds. With 8 workers starting simultaneously on CI, the actual startup time depends on available CPU.
// playwright-ct.config.ts
export default defineConfig({
workers: process.env.CI ? 4 : 2,
// ...
});Start conservative on CI. 4 workers is often faster than 8 for CT because Vite startup competition on constrained CI CPUs creates more overhead than the parallelism saves. Profile your actual suite.
Measuring Your Suite's Composition
Before optimizing, know where the time goes:
npx playwright test --reporter=json 2>/dev/null | \
node -e "
const r = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8'));
const suites = r.suites.flatMap(s => s.suites || []);
suites
.map(s => ({ file: s.file, duration: s.specs.reduce((acc, sp) => acc + sp.tests[0]?.results[0]?.duration ?? 0, 0) }))
.sort((a, b) => b.duration - a.duration)
.slice(0, 10)
.forEach(s => console.log(s.duration + 'ms', s.file));
"This surfaces the 10 slowest test files. If they're all in one component family (say, a complex data table), you have a different problem than if they're evenly distributed.
Sharding Across CI Machines
Playwright's --shard flag divides the test suite across N machines without coordination:
# Machine 1 of 4
npx playwright test --shard=1/4
# Machine 2 of 4
npx playwright test --shard=2/4Each shard gets a distinct, non-overlapping subset of test files. Playwright distributes files by estimated duration (if previous run data is available) or alphabetically.
GitHub Actions matrix strategy turns this into N parallel CI jobs:
# .github/workflows/ct.yml
name: Component Tests
on: [push, pull_request]
jobs:
ct:
name: CT Shard ${{ matrix.shard }}/${{ matrix.total }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
total: [4]
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 CT shard
run: npx playwright test --config=playwright-ct.config.ts --shard=${{ matrix.shard }}/${{ matrix.total }}
- name: Upload shard results
uses: actions/upload-artifact@v4
if: always()
with:
name: ct-results-shard-${{ matrix.shard }}
path: test-results/
retention-days: 7With 4 shards running in parallel on a 500-test suite, you go from ~10 minutes serial to ~2-3 minutes — the theoretical 4x speedup minus overhead.
Merging Shard Reports
Each shard produces its own report. Playwright can merge them:
merge-reports:
name: Merge CT Reports
needs: ct
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 shard results
uses: actions/download-artifact@v4
with:
pattern: ct-results-shard-*
path: all-results/
merge-multiple: true
- name: Merge reports
run: npx playwright merge-reports --reporter=html all-results/
- name: Upload merged report
uses: actions/upload-artifact@v4
with:
name: ct-report-merged
path: playwright-report/The merged HTML report shows all tests from all shards in a single view.
Reducing Vite Startup Overhead
Several strategies reduce per-worker startup cost.
Pre-bundle aggressively. Vite's dependency pre-bundling (optimizeDeps) runs on first start. Commit node_modules/.vite/ to cache or use Vite's force: false (default) combined with a warm cache in CI:
- name: Cache Vite CT dependencies
uses: actions/cache@v4
with:
path: node_modules/.vite
key: vite-ct-${{ hashFiles('package-lock.json') }}
restore-keys: vite-ct-Reduce worker count for small suites. If a shard has 50 tests, 2 workers is better than 8. The shard size drives the optimal worker count:
// playwright-ct.config.ts
const isCI = !!process.env.CI;
const shardIndex = process.env.SHARD_INDEX ? parseInt(process.env.SHARD_INDEX) : 1;
export default defineConfig({
workers: isCI ? 2 : 1, // CT benefits less from workers than E2E
});Group tests by component for cache locality. Playwright assigns test files to workers. If related component tests are in different files, the same Svelte/React component module gets compiled twice across two Vite instances. Colocating tests with their components and keeping related components in the same test file improves module cache hit rate within a worker:
src/
components/
DataTable/
DataTable.svelte
DataTable.ct.spec.ts # all DataTable tests here
Modal/
Modal.svelte
Modal.ct.spec.tsTrace Collection Strategy
Collecting traces for every CT test creates large artifacts that slow down CI and cost storage. Collect traces only on failure:
// playwright-ct.config.ts
export default defineConfig({
use: {
trace: 'on-first-retry', // Collect trace on first retry of failed tests
screenshot: 'only-on-failure',
},
retries: process.env.CI ? 1 : 0,
});on-first-retry means: if a test fails and is retried, collect a trace during the retry. You get trace data for genuine failures without collecting traces for passing tests.
Flakiness Detection with --repeat-each
Before shipping a new component test, verify it's deterministic:
npx playwright test --config=playwright-ct.config.ts ComponentName.ct.spec.ts --repeat-each=10This runs the test 10 times and reports any inconsistent results. Flaky CT tests are usually timing issues (not waiting for reactivity to settle) or test isolation issues (shared module state between tests).
For shared state issues, check that your store resets and mock handler resets happen in beforeEach or beforeMount, not just once in a beforeAll.
Tracking Suite Duration Over Time
Add a step to your merge job that extracts total duration from the merged report and posts it as a check:
- name: Report CT suite duration
run: |
DURATION=$(node -e "
const r = require('./playwright-report/report.json');
console.log(Math.round(r.stats.duration / 1000) + 's');
" 2>/dev/null || echo 'unknown')
echo "CT suite duration: $DURATION"
echo "CT_DURATION=$DURATION" >> $GITHUB_ENVTracking this over time (in a GitHub PR comment or a simple time-series log) catches regressions before they compound. A test suite that grows from 2 minutes to 8 minutes over 6 months doesn't feel like a crisis at any single point, but the graph tells the story.
The Practical Setup for 500+ CT Tests
Combining the above:
- 4 shards in CI (matrix strategy)
- 2 workers per shard
- Vite dependency cache keyed on
package-lock.json - Traces on first retry only
- Tests colocated with components
--repeat-each=5in local pre-commit hook for new CT files
A 500-test CT suite should complete in under 3 minutes with this setup on standard GitHub Actions runners. If it's taking longer, the bottleneck is almost always either Vite startup (reduce workers or add caching) or a small number of slow tests (profile with --reporter=json).
Component tests and E2E tests cover different things. HelpMeTest covers the third layer — full user journeys across a live application — that neither CT nor narrowly-scoped E2E handles well. Running all three layers efficiently is what keeps CI feedback loops fast enough to be useful.
Slow tests don't get run. Fast tests catch bugs before they ship.