Shard tests across parallel jobs in GitHub Actions
Speed up GitHub Actions tests by splitting one long test job into parallel shards with a matrix, so the suite finishes in a fraction of the wall-clock time.
How StarSling worksAI agents open the PR
StarSling agents inspect your workflow, apply this optimization, and open a reviewable PR automatically.
Do this
A test job that would run for many minutes as a single sequential run is split into N parallel shards via a matrix, each running its slice with the framework's native sharding flag (--shard for Playwright/Vitest/Jest, --partition for cargo-nextest, pytest-split for pytest). The critical path drops toward total / N plus per-job setup.123456
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
# Node 24 action versions. As of 2026-07-14, Playwright's docs still show the Node 20
# v4/v5 (playwright.dev/docs/test-sharding); these are the current equivalents, same behavior.
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 22
- run: npm ci
- run: npx playwright install --with-deps chromium
# strategy.job-total, not a literal 4: the denominator is the matrix size. Hardcode it
# and someone shrinks the matrix to [1, 2, 3] one day, the flag still says /4, and a
# quarter of the suite silently never runs. Green CI, untested code.
- run: npx playwright test --shard=${{ matrix.shard }}/${{ strategy.job-total }} --reporter=blob
# Each shard emits its own partial blob report. Keep them all, even when
# a shard fails, so the merge job below can see why.
- name: Upload blob report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: blob-report-${{ matrix.shard }}
path: blob-report
retention-days: 1
# OTHER FRAMEWORKS: only the RUN step is portable. The artifact path and the merge job
# below are Playwright's, and copying them for another runner is how you get a green
# build with an empty report.
#
# Jest, no merge step; each shard reports on its own:
# - run: npx jest --shard=${{ matrix.shard }}/${{ strategy.job-total }}
#
# Vitest, same contract as Playwright but ITS OWN paths. Note the reporter flag: without
# it the shards pass, emit no blob, and the merge finds nothing. Vitest writes blobs to a
# HIDDEN directory, so the upload also needs `path: .vitest-reports/*` and
# `include-hidden-files: true` (upload-artifact defaults that to false and would upload
# zero files), and the merge is `npx vitest run --merge-reports`:
# - run: npx vitest run --reporter=blob --shard=${{ matrix.shard }}/${{ strategy.job-total }}
#
# pytest-split. Commit a .test_durations file (`pytest --store-durations`), or the groups
# are balanced by assumed-average time and the shards skew:
# - run: pytest --splits ${{ strategy.job-total }} --group ${{ matrix.shard }}
#
# cargo-nextest >= 0.9.127 (older versions use hash:m/n):
# - run: cargo nextest run --partition slice:${{ matrix.shard }}/${{ strategy.job-total }}
# Playwright only. Without this job the run ends with 4 partial reports
# instead of one. It runs even if a shard failed, so failures still show up.
merge-reports:
if: ${{ !cancelled() }}
needs: [test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 22
- run: npm ci
- name: Download blob reports
uses: actions/download-artifact@v7
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- run: npx playwright merge-reports --reporter html ./all-blob-reports
- name: Upload HTML report
uses: actions/upload-artifact@v7
with:
name: html-report--attempt-${{ github.run_attempt }}
path: playwright-report
retention-days: 14Avoid this
The whole suite runs on one runner, so the PR waits on a single long pole.
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npx playwright test # every spec, one runner, one long poleSeen in the wild
Cal.com, Mastra, and Playwright split long suites across shards or weighted matrix jobs instead of waiting on one oversized runner.
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
node-version: [22]
shardIndex: [1, 2]
shardTotal: [2]
shardWeights: ['58:42'] strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4] matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8]
# ...
- name: Run Tests
run: yarn e2e --shard=${{ matrix.shard }}/${{ strategy.job-total }} --workers=4non-adjacent lines joined by # ...
Shipped by StarSling
On Mastra, StarSling agents tuned how the test suite uses parallel workers from both ends: sharding the slow E2E kitchen-sink across three parallel jobs to cut wall-clock, and capping vitest's per-core worker pool on the memory suite so it stopped exhausting the runner's RAM.
- Read the Mastra customer storyMerged 2026-04-29
Customer story
ci: shard E2E kitchen-sink across 3 parallel jobs
StarSling split Mastra's E2E kitchen-sink workflow across a shard matrix and passed the shard number into Playwright.
The current guide adds the Playwright blob-reporter and merge-reports guardrail you should include with this pattern.
Read the Mastra customer storyView PR #15888 in mastra-ai/mastra (opens in new tab) - Read the Mastra customer storyMerged 2026-03-06
Customer story
perf(memory): limit vitest parallelism to prevent OOM kills on CI
The other side of the parallelism dial: StarSling traced intermittent runner-lost-communication failures in the Memory Tests job to vitest's default one-worker-per-core pool exhausting RAM, and capped it at two worker threads so peak memory stayed within the instance instead of tripping the OOM killer.
This is the counterweight to sharding, not a substitute: shard across jobs for wall-clock, then right-size each job's in-process workers so a shard does not OOM.
Read the Mastra customer storyView PR #13937 in mastra-ai/mastra (opens in new tab)
Why it matters
Sharding is a top wall-clock lever: it directly parallelizes your slowest test job. The honest tradeoff is that it trades runner-minutes for speed, the same tests still run, and each extra shard adds fixed setup overhead (checkout, install), so your bill goes up even as the critical path comes down. Stack it with dependency and build caching so per-shard setup doesn't eat the gains, and make sure your runners have enough concurrency to start every shard at once instead of queueing.
When to use
Use it when
Any test job whose wall-clock is over ~5 minutes and whose framework supports sharding. Find this automatically with /ci-speedup.
Be careful when
When the suite is short, or when per-job setup already dominates the runtime, past a point, adding shards stops moving wall-clock because the setup tax floors it. Fix caching first, then shard.
Verify on your repo
Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to audit and fix this practice in your own repo.
Inspect this repo's .github/workflows for a long test job that runs the whole suite on a single runner with no `matrix` shard axis. Identify the slowest test job (wall-clock over ~5 minutes) and confirm its framework supports sharding (`--shard` for Playwright / Jest / Vitest, `--partition` for cargo-nextest, `pytest-split` for pytest). Add a `matrix` with a `shard` axis and pass the framework's native sharding flag so the suite splits across N parallel jobs, and make sure dependency and build caching is in place so per-shard setup does not eat the gains. Show me the diff and open a PR rather than applying it blindly.
Ground these changes in the upstream docs before you edit: https://playwright.dev/docs/test-sharding. If you cannot fetch them, say so rather than guessing, and cite what you used in the PR description.Prefer to check by hand?
Identify test jobs with wall-clock over ~5 minutes.
Check whether the framework supports sharding (
--shardfor Playwright/Jest/Vitest,--partitionfor nextest,pytest-split).Confirm no
matrixshard axis is configured, a single job running the whole suite is the flag.
Go further
One fix, all of them, or forever.
You have the prompt for this one practice. Here is how much further you can take it, each step doing more for you than the last.
Fix this one thing
Copy the prompt above
Hand OPT24 to your coding agent and fix it in your repo today.
Fix everything, once
Install the ci-score skill
One prompt grades your whole workflow config against all 11 CI Score checks, this one included, and hands your agent a ranked fix for every gap it finds. Open source, runs locally. It grades configuration, not speed.
Keep it fixed, forever
Install the StarSling GitHub App
Connect GitHub and the fixes stay applied as your CI evolves, with agents that keep inspecting your workflows and opening optimization PRs you review.
More best practices for GitHub Actions
Where to go next in the CI best-practices catalog.
- Parallelization
Build and test only what changed in GitHub Actions
Speed up GitHub Actions and cut runner minutes by scoping your slowest build/test job to only the packages a PR actually changed, using your monorepo tool's affected mode, with a mandatory full-run fallback so a resolution error never silently skips work.
- Caching & Setup
GitHub Actions cache: dependencies, keys, and cache hits
The GitHub Actions cache reuses package-manager downloads between workflow runs.
- Runner & Queue
Right-size GitHub Actions runners without slowing CI
Right-size a GitHub Actions job by comparing the same commit on the current and smaller runner, then keep the smaller size only when pass rate and wall-clock remain equivalent.
More ways to improve GitHub Actions
If you do not know why a run is slow yet, start with the diagnostic guide. Let an agent find which of these applies: /ci-speedup. If your workflows are already optimized but still slow, see our guide to fast GitHub Actions and GitHub Actions runner alternatives. Building containers in CI? The Docker workflow guide covers layer caching end to end. Want to see how your configuration measures up before changing anything? CI Score grades workflow config against a pass/fail rubric of these practices. It is not a speed measurement. To find exploitable workflow paths before attackers do, ci-secure checks the ten critical GitHub Actions attack vectors and lets you choose which fixes to apply.
- GitHub Actions too slow
- Fast GitHub Actions
- GitHub Actions alternatives
- GitHub Actions pricing
- Self-hosted GitHub Actions runners
- Docker CI on GitHub Actions
- Install the free /ci-score skill to improve your GitHub Actions setup
- Install the /ci-secure skill to close critical attack vectors in GitHub Actions
- Browse GitHub Actions agent skills
FAQ
Does sharding save money or just time?
Just time. Sharding lowers wall-clock (the critical path) but raises runner-minutes, because the same test work runs across more jobs and each shard repeats setup. It's the right lever when speed matters more than the bill, pair it with caching to keep the added overhead small.
How many shards should I use?
Increase shards until the per-shard setup overhead (checkout, install, browser download) starts to dominate, that's the floor. Caching that setup lets you shard further before diminishing returns kick in.
What is the shard command for Jest, Vitest, or pytest?
The matrix is the same for all of them, and every shard flag below is 1-based. What changes is whether you also need a merge step. Playwright and Vitest both do: pass --reporter=blob and each shard writes a partial blob report, so without a merge job you finish with N of them instead of one. Playwright merges with npx playwright merge-reports; Vitest merges with npx vitest run --merge-reports, a mode that reads the existing blobs rather than running the tests again. Watch out for one Vitest difference: it writes its blobs to a hidden directory, so its upload step also needs include-hidden-files: true, which actions/upload-artifact defaults to false. Jest takes the same flag (npx jest --shard=${{ matrix.shard }}/${{ strategy.job-total }}) and has no built-in merge, so each shard reports on its own. pytest has no built-in sharding: install pytest-split, run pytest --splits ${{ strategy.job-total }} --group ${{ matrix.shard }}, and commit a .test_durations file (pytest --store-durations) or the groups are balanced by assumed-average time and the shards skew. Rust uses cargo-nextest 0.9.127 or newer: cargo nextest run --partition slice:${{ matrix.shard }}/${{ strategy.job-total }}.
Sources
1Playwright · test sharding (opens in new tab)
2GitHub Actions · matrix strategy (opens in new tab)
3Jest CLI · --shard (opens in new tab)
4Vitest CLI · --shard (opens in new tab)
5Vitest · sharding needs --reporter=blob and --merge-reports (opens in new tab)
Last updated 2026-08-19