Parallelizing Test Suites in CI

A frontend test suite grows until it becomes the pipeline’s critical path, and by the time anyone measures it, a pull request costs eighteen minutes of which fifteen are tests. Sharding fixes the wall-clock problem β€” but only if the split is balanced, the reports are merged, and the shard count stops at the point where per-job overhead eats the gains.

This guide covers how shard assignment actually works, how to balance splits on recorded durations rather than file counts, how to merge coverage so quality gates still see the whole picture, and how to pick a shard count from your own numbers rather than a round one. It pairs with incremental builds and affected detection, which reduces how much you test; sharding reduces how long testing what remains takes.


Prerequisites


How Shard Assignment Works

Every sharding implementation is the same three-line algorithm: enumerate the test units in a deterministic order, partition that ordered list into N groups, and have runner i execute group i. Everything that goes wrong with sharding comes from one of those three steps.

File-Count Sharding vs Duration-Balanced Sharding Two stacked groups of four horizontal runner bars. In the top group, split by file count, shard timings are 11, 4, 3 and 2 minutes so the job takes 11 minutes. In the bottom group, split by recorded duration, all four shards land near 5 minutes so the job takes 5 minutes. SPLIT BY FILE COUNT β€” 20 files Γ· 4 shard 0 11m 00s β€” holds 3 end-to-end specs shard 1 4m 00s shard 2 3m 00s shard 3 2m 00s β€” idle for 9 minutes SPLIT BY RECORDED DURATION shard 0 5m 00s shard 1 4m 54s shard 2 5m 06s shard 3 4m 48s β€” job completes in 5m 06s, not 11m

Enumeration order must be stable. Runners glob the filesystem independently, and glob order is not guaranteed identical across machines or across operating systems. If shard 2 enumerates a different list than shard 3, some files run twice and others never run at all β€” and a test that never runs reports as a pass. Sort the discovered list explicitly before partitioning; every mature runner does this internally, which is the main reason to prefer native --shard over a hand-rolled split pipeline.

Partitioning is the only interesting decision. The default is contiguous or round-robin assignment by index, which balances file counts and therefore balances nothing that matters. Two files differ in runtime by two orders of magnitude in any suite that mixes unit and browser tests. Duration-balanced partitioning β€” greedy longest-first bin packing over recorded timings β€” takes ten lines and typically halves the worst shard.

Execution must be independent. Sharded tests run in fresh processes on separate machines, so anything shared implicitly (a seeded database row, a fixture file written by an earlier test, an ordering assumption) breaks. This is the part teams underestimate: the first sharded run surfaces every latent inter-test dependency at once.

There is one more property worth naming, because it decides whether sharding is even the right tool. Sharding only helps when the suite is CPU- or browser-bound on the runner. If the tests spend most of their time waiting on a shared staging API or a single database, splitting them across four machines produces four clients contending for the same bottleneck β€” the wall-clock barely moves and the failure rate climbs as the dependency saturates. Check where the time actually goes before adding runners: if a serial run shows 80% of each spec’s duration in network wait, the fix is a faster or isolated backend, not more parallelism. Sharding multiplies whatever the tests already do, including their contention.


Step-by-Step Implementation

1. Measure before splitting

You cannot balance what you have not measured, and the measurement is also how you decide the shard count.

# Playwright: emit a JSON report with per-spec durations from one serial run.
npx playwright test --reporter=json > timings.json

# Rank the slowest specs β€” this is the list that determines your floor.
jq -r '.suites[].specs[] | "\(.tests[0].results[0].duration/1000 | floor)s\t\(.file)"' \
  timings.json | sort -rn | head -10

If the single slowest file takes four minutes, no shard count will bring the suite below four minutes. That file must be split before parallelism helps β€” which is a test-design fix, not a CI fix.

2. Declare the matrix

# .github/workflows/test.yml
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false          # a failure in shard 1 must not hide failures in shard 3
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - run: npx playwright install --with-deps chromium   # ~40s: the per-shard overhead floor
      - name: Run shard ${{ matrix.shard }}
        run: npx playwright test --shard=${{ matrix.shard }}/4 --reporter=blob
      - uses: actions/upload-artifact@v4
        with:
          name: blob-report-${{ matrix.shard }}
          path: blob-report
          retention-days: 1

fail-fast: false is not optional. With the default, the first shard to fail cancels the others, so a developer fixes one failure, pushes, and discovers a second β€” turning a single red run into three round trips.

3. Balance the split on recorded durations

Store the timings from step 1 in the repository and partition greedily: sort files longest-first, and repeatedly assign the next file to whichever shard currently has the least accumulated time.

// scripts/plan-shards.mjs β€” greedy longest-processing-time bin packing.
import { readFileSync } from 'node:fs';

const timings = JSON.parse(readFileSync('test-timings.json', 'utf8')); // { "specs/checkout.spec.ts": 84.2, ... }
const shardCount = Number(process.argv[2] ?? 4);
const shards = Array.from({ length: shardCount }, () => ({ files: [], total: 0 }));

// Longest first: placing big items early is what keeps the tail balanced.
for (const [file, seconds] of Object.entries(timings).sort((a, b) => b[1] - a[1])) {
  const target = shards.reduce((min, s) => (s.total < min.total ? s : min));
  target.files.push(file);
  target.total += seconds;
}

// Unknown files (new tests with no timing yet) go to the lightest shard.
console.log(JSON.stringify(shards.map((s) => s.files)));

Verify the plan is actually balanced before trusting it:

node scripts/plan-shards.mjs 4 | jq -r '.[] | length'   # file counts will differ β€” that is correct

A spread wider than about 15% between the heaviest and lightest shard means the timing data is stale or one file is too large to pack around. Greedy longest-first packing is not optimal in the theoretical sense, but it is provably within a third of optimal and needs no tuning β€” and in practice the residual imbalance is dominated by run-to-run variance on shared runners anyway, so a smarter packer buys nothing measurable.

4. Merge the reports

Per-shard reports are individually useless to a coverage gate. Merge them in a dependent job.

  merge:
    needs: test
    if: always()             # merge and report even when a shard failed
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with: { pattern: blob-report-*, path: all-blobs, merge-multiple: true }
      - run: npx playwright merge-reports --reporter=html,github ./all-blobs
      - uses: actions/upload-artifact@v4
        with: { name: html-report, path: playwright-report }

The if: always() condition is what makes the merge job a reporting step rather than a second gate. Without it, a single failing shard skips the merge, the developer gets four separate partial reports and no coverage number, and the most common response is to re-run the whole matrix to find out what actually broke. The fan-out/fan-in shape below is the topology worth aiming for: the matrix decides pass or fail, and a single downstream job turns four partial results into one artifact a human can read.

Shard Fan-Out and Report Fan-In Topology A build-and-cache job on the left fans out to four shard jobs stacked vertically, each producing a blob report artifact. All four converge on a merge job on the right that runs even on failure, producing a single HTML report and one merged coverage threshold check. Install & cache restored by every shard shard 1/4 β†’ blob-report-1 shard 2/4 β†’ blob-report-2 shard 3/4 β†’ blob-report-3 shard 4/4 β†’ blob-report-4 merge (if: always) one HTML report one coverage gate status check

For unit-test coverage the equivalent is nyc merge followed by a single threshold check:

npx nyc merge ./coverage-shards ./coverage/merged.json
npx nyc report --temp-dir ./coverage --check-coverage --lines 85   # gate on the merged total

Configuration Reference

Option Type Default Effect
--shard=i/n string none Runs partition i of n; the runner sorts specs before partitioning
strategy.fail-fast boolean true When true, one failing shard cancels the rest and hides other failures
strategy.max-parallel integer unlimited Caps concurrent shards; use it to stay inside a runner quota
--reporter=blob string list Emits a mergeable intermediate report instead of a terminal summary
workers integer CPU count Parallelism inside one shard β€” multiplies with shard count
retention-days integer 90 Blob reports are disposable; 1 day is enough and cuts artifact storage cost
merge-multiple boolean false Downloads several artifacts into one directory, required for merging

Note that workers and shard count compound: four shards each running four workers is sixteen concurrent browsers, which will exhaust a shared database or a rate-limited staging API long before it exhausts CPU.


Integration with Adjacent Topics

Sharding sits between two neighbours in this section and changes the economics of both.

Affected detection runs first. Incremental builds and affected detection in monorepos decides which suites need to run at all; sharding then splits whatever survived that filter. Applying them in the other order β€” sharding the full suite and letting each shard skip β€” pays the per-shard setup cost for shards that then do nothing.

Caching sets the overhead floor. Every shard repeats checkout, install, and browser download. Without the dependency caching described in best practices for caching npm vs Yarn vs pnpm in CI, an eight-shard matrix pays eight cold installs and can be slower end to end than four warm ones.

Concurrency limits cap the ceiling. A 12-shard matrix on a queue with 10 free runners does not run 12-wide; it runs 10-wide and then 2-wide, which is worse than an 8-shard matrix that fits. The queue behaviour is covered in optimizing pipeline concurrency and queue limits.


Performance and Cost Impact

Wall-clock falls with shard count; total runner minutes rise, because every shard repays the same fixed setup. The crossing point is the only number that matters, and it is specific to your setup cost.

Wall-Clock vs Total Runner Minutes by Shard Count Two lines over shard counts 1 to 12. Wall-clock drops steeply from 16 minutes at one shard to about 5 minutes at four shards, then flattens near 4 minutes. Total runner minutes rise steadily from 16 to 54. A shaded band marks four to six shards as the efficient range where wall-clock gains stop repaying the added minutes. minutes 0 20 40 60 efficient range 16m wall-clock 5m 4m β€” no further gain 54 runner-minutes billed 1 4 6 8 12 shard count Measured on a 16-minute Playwright suite with 70 seconds of fixed per-shard setup

Read the flat tail carefully: past six shards this suite buys 30 seconds of wall-clock for 18 extra runner-minutes per run. At 200 runs a week that is 60 runner-hours a month for half a minute a developer barely notices. The right stopping point is where the marginal wall-clock gain drops below roughly a minute β€” usually when the slowest shard approaches the fixed setup cost, here 70 seconds.


Troubleshooting

Error: No tests found on a subset of shards. The shard count in the command does not match the matrix length β€” --shard=${{ matrix.shard }}/4 with a five-entry matrix produces a shard 5 of 4. Derive both from one source: build the matrix from fromJSON over a computed list, or hard-code the denominator next to the matrix definition and review them together.

Shard timings drift apart over weeks. The timing file is stale; new specs were added and all landed in whichever shard was lightest at generation time. Regenerate timings on a schedule β€” a nightly full serial run that commits the updated file is the simplest durable approach β€” rather than during pull-request runs, where the data would be contaminated by shard-level parallelism.

Coverage drops sharply after enabling sharding. The gate is reading one shard’s report instead of the merged one, usually because the artifact download pattern matched a single file. Confirm the merged report exists and covers the expected file count before believing a coverage regression that appeared on the same commit that enabled sharding.

Tests pass serially and fail when sharded. Real inter-test coupling, exposed. The usual causes are a shared database record created by an earlier test in the same file order, a fixture written to a fixed path, or a global mock left installed. Reproduce locally with --shard=2/4 to get the exact subset, then fix the isolation β€” retrying the shard hides a bug that will eventually appear in production.


Frequently Asked Questions

How many shards should a frontend test suite use?

Enough that the slowest shard approaches your fixed per-job overhead β€” checkout, install, and browser download, typically 60–90 seconds. Past that, each additional shard adds a full setup cost to buy a shrinking slice of test time, so wall-clock flattens while billed minutes keep climbing. For most suites in the 10–20 minute range that lands between four and six.

Why do my shards finish at wildly different times?

Because the default split balances file counts, not durations. A shard holding three browser specs runs many times longer than one holding forty unit files. Partition on recorded per-file timings with the greedy longest-first algorithm above; it is ten lines and typically halves the worst shard.

Does sharding make flaky tests worse?

It exposes them rather than causing them. Tests that quietly depended on execution order or on a fixture an earlier test created start failing once the order changes. That is genuine breakage the serial run was masking; fix the isolation instead of adding retries, or the same coupling will surface later as a real defect.

Can coverage thresholds be enforced per shard?

No β€” each shard exercises only part of the codebase, so its coverage figure is meaningless alone and any per-shard threshold will fail constantly. Merge the per-shard reports and gate on the merged total, as in step 4.


← Back to Build Optimization & Caching Strategies