Sharding Vitest Across GitHub Actions Runners

A Vitest suite that grows past a few minutes on a single runner becomes the slowest step in every pull request, and the only way to shorten wall-clock time without deleting tests is to run them on more than one machine at once. This guide is for platform and CI engineers running Vitest 1.x or 2.x on GitHub Actions who need to shard a suite across a matrix of runners, then merge the per-shard results and coverage back into a single verdict. We cover why sharding works, a reproducible matrix, the exact flags, how to merge coverage so a threshold check still passes, and the failure modes — uneven shards, order dependence, and dropped reports — that catch teams the first time. Sharding is the highest-leverage lever in CI test orchestration because it attacks wall-clock time directly.

Root Cause Analysis

The slowness you are fighting is structural, not accidental. A test runner on one machine executes files serially or with a bounded worker pool, so total wall-clock time is the sum of every test’s duration divided by local core count. CI runners are small — two cores is typical — so that divisor is tiny, and the sum grows with every test you add. The suite therefore gets monotonically slower as the codebase matures, and no amount of in-process optimisation changes the fundamental shape: one machine, finite cores, linear growth.

Sharding changes the divisor by adding machines. Instead of one runner doing all the work, n runners each do a deterministic 1/n slice of the test files in parallel, so wall-clock time falls toward the single-runner time divided by n, plus a fixed per-runner overhead of checkout, Node setup, and install. The reason this is safe is that Vitest partitions at the file level using a stable hash, so the slices are disjoint and their union is the whole suite — every test runs exactly once, just not all on the same machine. The cost you accept in exchange is that each shard reports only its slice, so results and coverage arrive fragmented and must be reassembled, which is where most of the real engineering lives. Choosing a runner that starts fast matters here too, which is part of why teams weigh Vitest vs Jest for CI speed before scaling out.

One suite partitioned into four disjoint shards Vitest hashes each test file to a shard index so the four slices are disjoint and their union is the entire suite, each running on its own runner. Full suite files hashed by path Runner 1/4 files a,e,i Runner 2/4 files b,f,j Runner 3/4 files c,g,k Runner 4/4 files d,h,l disjoint slices — union equals the full suite, each file runs once
Vitest hashes files into disjoint shards so every test runs exactly once.

Reproducible Setup

Start from a suite that passes unsharded, then confirm the shard flag partitions it. Vitest’s --shard=index/count is built in, so no plugin is needed.

npm install -D vitest @vitest/coverage-v8
npx vitest run --shard=1/4   # runs only the first quarter of files
npx vitest run --shard=2/4   # a disjoint second quarter
// vitest.config.ts — a coverage reporter that merges cleanly across shards
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['json'],       // json merges; html does not
      reportsDirectory: './coverage',
    },
  },
});

The json reporter is deliberate: it emits a machine-mergeable coverage-final.json per shard, whereas html and text are terminal formats you cannot recombine. Generate the mergeable format on each shard and render the human report once, after merging.

Implementation

Step 1 — Define the matrix and pass the shard index. GitHub Actions expands matrix.shard into one job per value. Pass each value as the numerator of --shard and keep the denominator equal to the matrix length.

# .github/workflows/test.yml
name: test
on: pull_request
jobs:
  test:
    strategy:
      fail-fast: false        # let every shard finish so no report is lost
      matrix:
        shard: [1, 2, 3, 4]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version-file: '.nvmrc', cache: 'npm' }
      - run: npm ci
      - run: npx vitest run --shard=${{ matrix.shard }}/4 --coverage
      - uses: actions/upload-artifact@v4
        with:
          name: coverage-${{ matrix.shard }}
          path: coverage/coverage-final.json

Setting fail-fast: false on the matrix is critical when you are collecting coverage — the default true cancels sibling shards on the first failure, and a cancelled shard uploads no report, which breaks the merge step. This is a different setting from Vitest’s own bail, covered in failing fast with bail in large test suites.

Step 2 — Collect the per-shard artifacts in a merge job. A second job depends on the matrix, downloads every shard’s coverage, and combines them.

  coverage:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version-file: '.nvmrc', cache: 'npm' }
      - run: npm ci
      - uses: actions/download-artifact@v4
        with: { pattern: coverage-*, path: coverage-shards, merge-multiple: false }
      - run: node scripts/merge-coverage.mjs

Step 3 — Merge the coverage maps. Istanbul’s coverage map API sums per-file hit counts across shards into one report you can threshold.

// scripts/merge-coverage.mjs
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import libCoverage from 'istanbul-lib-coverage';

const map = libCoverage.createCoverageMap({});
const dirs = readdirSync('coverage-shards');

for (const dir of dirs) {
  const json = JSON.parse(readFileSync(`coverage-shards/${dir}/coverage-final.json`, 'utf8'));
  map.merge(json);
}

writeFileSync('coverage-merged.json', JSON.stringify(map.toJSON()));
console.log(`Merged coverage from ${dirs.length} shards.`);

Step 4 — Gate on the merged report, never a single shard. A per-shard coverage number is meaningless because each shard only touches its slice; only the merged map reflects the true suite. Feed the merged JSON into your threshold check, which for a monorepo follows enforcing coverage thresholds in a monorepo.

Step 5 — Size the shard count from suite time, not from ambition. Aim for each shard to run in roughly three minutes: divide the single-runner suite time by three and round to a small integer. Beyond that, per-runner overhead dominates and you pay linearly more billed minutes for shrinking wall-clock gains.

Per-shard coverage artifacts merged into one gated report Each shard uploads a partial coverage JSON, a merge job combines them with the Istanbul coverage map, and the single merged report is checked against the threshold. shard 1 json shard 2 json shard 3 json shard 4 json merge job coverageMap.merge threshold gate merged report
Only the merged coverage report is fit to gate on; per-shard numbers are partial.

Verification

Prove three properties before you trust a sharded pipeline. First, coverage of the file space: dump each shard’s file list with --shard=i/n and confirm the lists are pairwise disjoint and their union equals the unsharded list. Second, verdict preservation: run the suite unsharded and sharded against the same commit and confirm identical pass/fail. Third, merge integrity: confirm the merged coverage percentage matches an unsharded coverage run within rounding.

The two assertions that prove a correct partition The sum of shard file counts must equal the union size so no file runs twice, and the union must equal the full suite so no file is dropped. No overlap sum(shard sizes) = |union| no file ran twice else two shards collide No gap |union| = |full suite| no file was dropped else a coverage hole
A partition is correct only when both the no-overlap and no-gap assertions hold.
# assert the shards partition the file space with no overlap or gaps
npx vitest run --shard=1/4 --reporter=json --outputFile=s1.json
npx vitest run --shard=2/4 --reporter=json --outputFile=s2.json
npx vitest run --shard=3/4 --reporter=json --outputFile=s3.json
npx vitest run --shard=4/4 --reporter=json --outputFile=s4.json
# count unique test files across all shards, compare to the full run
npx vitest run --reporter=json --outputFile=full.json
// scripts/assert-partition.ts — union equals full, intersection empty
import { readFileSync } from 'node:fs';

const files = (p: string): Set<string> =>
  new Set(JSON.parse(readFileSync(p, 'utf8')).testResults.map((r: any) => r.name));

const shards = ['s1.json', 's2.json', 's3.json', 's4.json'].map(files);
const union = new Set(shards.flatMap((s) => [...s]));
const full = files('full.json');

const total = shards.reduce((n, s) => n + s.size, 0);
if (total !== union.size) throw new Error('Shards overlap — a file ran twice.');
if (union.size !== full.size) throw new Error('Shards drop files — coverage gap.');
console.log('Partition verified: disjoint and complete.');

If total !== union.size, two shards ran the same file — usually a stale Vitest version whose hashing changed between the version that ran locally and the one CI installed. If the union is smaller than the full run, a shard was cancelled or its artifact failed to upload, and your fail-fast setting is the first suspect. Run these assertions on every pull request rather than as a one-off, because the partition can silently break when you bump Vitest, change the include globs, or split a large test file, and a broken partition either double-charges you for duplicated work or opens a coverage gap that no single green build will reveal.

Troubleshooting

The long-pole shard. One shard takes far longer than the others because Vitest distributes files, not cases, and a single fat file holds most of the slow tests. Splitting shards more finely will not help — the fat file cannot be split across shards. Break the file into several test files, or move its slow integration cases behind a project-level split so the distribution evens out.

Missing coverage after a failure. The merge job errors because a shard uploaded no artifact. The cause is almost always fail-fast: true on the matrix cancelling siblings when one shard fails, or an upload-artifact step that is skipped on failure. Set the matrix fail-fast: false and ensure the upload step runs with if: always() so even a failing shard reports its partial coverage.

Order-dependent tests that only fail when sharded. A test passes in the full run but fails in its shard because it depended on state a neighbouring file set up, and sharding moved that neighbour to a different runner. This is a latent test bug, not a sharding bug; fix it by making the test self-contained, and until then contain it through quarantining flaky tests in CI.

FAQ

How many shards should I use?

Divide your single-runner suite time by about three minutes and round to a small whole number, so an eighteen-minute suite becomes roughly six shards. The three-minute target balances the parallel speed-up against the fixed per-runner overhead of checkout, Node setup, and install, which every shard pays regardless of how little test work it does. Past that point you spend linearly more billed runner-minutes for progressively smaller wall-clock gains, so more shards is not automatically better.

Does sharding change which tests pass or fail?

It must not, and you should assert that it does not. Vitest partitions files deterministically and each file runs exactly once, so a correct suite returns the same verdict sharded or not. When a verdict does change, the sharding did not break the test — it exposed a pre-existing order dependency or shared-state assumption that happened to be masked by the file execution order on a single runner. Treat that as a test bug to fix, and keep a verdict-preservation assertion in the pipeline so the regression is caught automatically.

Can I shard and collect a single coverage report?

Yes, and you must merge to get a meaningful number. Emit the json coverage reporter on each shard, upload each coverage-final.json as an artifact, then combine them in a dependent job with Istanbul’s createCoverageMap().merge(). Gate only on the merged map — a per-shard percentage is always partial because each shard exercises only its slice of files, so gating on one shard would either pass trivially or fail spuriously.

Do I need a self-hosted runner to shard?

No. Sharding is just more GitHub-hosted jobs running concurrently, bounded by your account’s concurrency limit rather than by any special runner type. Self-hosted runners can lower per-minute cost or provide more cores per shard, but the mechanism — matrix plus --shard plus a merge job — is identical on hosted and self-hosted infrastructure.