Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Compare the Nx approach

Splitting Monorepo Tests into Parallel CI Shards

Your affected set is already minimal but the remaining test suite still takes too long on one runner. This page shows how to shard the suite across parallel jobs so wall-clock time drops roughly linearly with runner count.

Exact symptoms and error messages

A single test job runs the whole affected suite serially:

Exact symptoms and error messages A single test job runs the whole affected suite serially: Exact symptoms and error messages A single test job runs the whole affected suite serially:
Exact symptoms and error messages — the core idea of this section at a glance.
Test Files  212 passed (212)
     Tests  3841 passed (3841)
  Duration  14m 06s

The work is already scoped to affected packages — it is simply too much for one machine.

Root cause analysis

Once affected detection and caching have removed redundant work, the remaining cost is genuine and serial. Sharding addresses it by partitioning the test set deterministically across runners so each executes a disjoint slice. This composes with the affected filter from CI/CD Pipeline Optimization for Monorepos — shard the changed set, not the whole repo.

Root cause analysis Once affected detection and caching have removed redundant work, the remaining cost is genuine and serial. Root cause analysis Once affected detection and caching have removed redundant work, the remaining cost is genuine and serial.
Root cause analysis — the core idea of this section at a glance.

Sharding is the last lever precisely because it does not remove work — it only spreads it. Affected detection and caching reduce the amount of work; sharding reduces the wall-clock time of whatever work remains by running disjoint slices in parallel. Applying sharding before the other two is a common mistake: you end up parallelizing the full suite on every commit, paying N runners' startup cost to run tests that affected would have skipped entirely.

The correctness subtlety is partition determinism. A shard must always contain the same tests given the same input, or a flaky partition can hide a failure on one run and surface it on another. Framework-native sharding (Vitest's --shard, Jest's --shard) partitions deterministically by test file, which is why it is preferable to ad-hoc splitting by directory that can drift as files are added.

Once affected detection and caching have removed redundant work, the remaining test suite is genuine and serial, and sharding addresses it by spreading that work across parallel runners. The reason sharding comes last among the optimization levers is that it does not reduce the amount of work — it reduces the wall-clock time of whatever work remains by running disjoint slices at once. Applying it before affected detection is a common mistake, because you then pay for parallelism on tests that affected would have skipped entirely.

The correctness requirement for sharding is deterministic partitioning: a shard must always contain the same tests given the same input, or a flaky partition can hide a failure on one run and surface it on another. Framework-native sharding — a test runner's own --shard flag — partitions deterministically by test file, which is why it is preferable to an ad-hoc split by directory that drifts as files are added. Combined with the affected filter, each shard runs its slice of the changed set rather than the whole repository.

Resolution and configuration patch

Resolution and configuration patch Each runner executes one quarter of the affected tests; a final required check gates on all shards passing. Resolution and configuration patch Each runner executes one quarter of the affected tests; a final required check gates on all shards passing.
Resolution and configuration patch — the core idea of this section at a glance.
  test:
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - run: pnpm install --frozen-lockfile --ignore-scripts
      - run: pnpm vitest run --shard=${{ matrix.shard }}/4 --filter='...[origin/main]'

Each runner executes one quarter of the affected tests; a final required check gates on all shards passing.

Partition the affected suite across a matrix of runners, gated on a single aggregating check:

  test:
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - run: pnpm install --frozen-lockfile --ignore-scripts
      - run: pnpm vitest run --shard=${{ matrix.shard }}/4 --filter='...[origin/main]'

fail-fast: false keeps one shard's failure from cancelling the others, so a single run surfaces every failure; a final required check depends on all shards passing before a merge is allowed.

CLI validation and debug commands

CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows. CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows.
CLI validation and debug commands — the core idea of this section at a glance.
# Run one shard locally to confirm partitioning
pnpm vitest run --shard=1/4
# Verify shards are balanced by timing each
for s in 1 2 3 4; do time pnpm vitest run --shard=$s/4; done

Prevention and CI guardrails

  • Set fail-fast: false so one shard's failure does not cancel the others.
  • Gate merges on a single required check that depends on every shard.
  • Keep shard count proportional to suite size — over-sharding wastes runner startup time.
  • Shard the affected set, never the entire repo, so small changes stay cheap.
Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows. Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows.
Prevention and CI guardrails — the core idea of this section at a glance.
  • Set fail-fast: false so one shard's failure does not cancel the others.
  • Gate merges on a single required check that depends on every shard.
  • Keep shard count proportional to suite size — over-sharding wastes runner startup time.
  • Shard the affected set, never the whole repo, so small changes stay cheap.

Choosing a shard count that actually helps

More shards is not automatically faster. Each shard pays a fixed cost — checkout, install, warm-up — before it runs a single test, so past a certain point adding shards just multiplies overhead. The sweet spot is where per-shard runtime approaches that fixed cost but does not drop below it.

Shard-count sweet spot Balance per-shard runtime against fixed overhead. time affected suite total runtime ÷ target per shard shard count re-measure as it grows keep it tuned
Pick a shard count where test time approaches — but stays above — fixed overhead.

Measure before committing to a number. Time the full affected suite, divide by your target per-shard runtime (say, two to three minutes of actual testing), and use that as the shard count. Re-check it as the suite grows, because a count that was right at 200 tests wastes runners at 50 and under-parallelizes at 800. The honest metric is total billed runner-minutes versus wall-clock saved: sharding is worth it only while the wall-clock win justifies the extra runner-minutes the overhead costs.

Balancing shards so one isn't the bottleneck

Wall-clock time is set by the slowest shard, not the average, so an unbalanced partition wastes the parallelism you paid for. If shard 3 holds all the slow integration tests, the other shards finish early and idle while it grinds on. Framework sharding partitions by file count, which is a good first approximation but ignores per-file runtime.

Shard balance Wall-clock is set by the slowest shard, not the average. shard 1 120s shard 2 130s shard 3 (slow) 240s shard 4 125s
An unbalanced split leaves fast shards idle while the slowest sets the pace.

For suites with a few known-slow files, either split those into their own shard deliberately or use a runner that partitions by timing data from previous runs. The check is simple: time each shard and look at the spread. A healthy split has shards finishing within a small window of each other; a large spread means the partition is uneven and the fix is to rebalance where the slow tests land, so no single shard becomes the pipeline's bottleneck.

Choosing a shard count that helps

More shards is not automatically faster, because each shard pays a fixed cost — checkout, install, warm-up — before it runs a single test. Past a certain point, adding shards just multiplies that overhead. The sweet spot is where per-shard test runtime approaches the fixed cost but does not drop below it, so the parallelism is buying real wall-clock reduction rather than paying startup cost for a handful of tests.

Choosing a shard count that helps More shards is not automatically faster, because each shard pays a fixed cost — checkout, install, warm-up — before it r Choosing a shard count that helps More shards is not automatically faster, because each shard pays a fixed cost — checkout, install, warm-up — before it runs a single test.
Choosing a shard count that helps — the core idea of this section at a glance.

Measure before committing to a number. Time the full affected suite, divide by a target per-shard runtime — say two to three minutes of actual testing — and use that as the shard count. Re-check it as the suite grows, because a count that was right at 200 tests wastes runners at 50 and under-parallelizes at 800. The honest metric is total billed runner-minutes versus wall-clock saved: sharding is worth it only while the wall-clock win justifies the extra runner-minutes the overhead costs, so a count chosen by measurement rather than guesswork is what keeps sharding an efficiency gain rather than a way to burn CI budget.

Balancing shards so one isn't the bottleneck

Wall-clock time is set by the slowest shard, not the average, so an unbalanced partition wastes the parallelism you paid for. If shard 3 holds all the slow integration tests, the other shards finish early and idle while it grinds on, and the run takes as long as that one heavy shard. Framework sharding partitions by file count, which is a good first approximation but ignores per-file runtime, so a few known-slow files can concentrate in one shard and unbalance it.

Balancing shards so one isn't the bottleneck Wall-clock time is set by the slowest shard, not the average, so an unbalanced partition wastes the parallelism you paid Balancing shards so one isn't the bottleneck Wall-clock time is set by the slowest shard, not the average, so an unbalanced partition wastes the parallelism you paid for.
Balancing shards so one isn't the bottleneck — the core idea of this section at a glance.

The fix depends on the imbalance. For suites with a few known-slow files, either split those into their own shard deliberately or use a runner that partitions by timing data from previous runs, so slow tests are distributed rather than clustered. The check is simple: time each shard and look at the spread. A healthy split has shards finishing within a small window of each other; a large spread means the partition is uneven and the fix is to rebalance where the slow tests land. Keeping the shards balanced is what ensures the wall-clock time actually drops toward the ideal of the total time divided by the shard count, rather than being dragged out by a single overloaded shard.

Sharding and flaky, order-dependent tests

Sharding interacts with flaky and order-dependent tests in ways worth anticipating, because the parallelism can expose latent bugs that a serial run never surfaced. Since each shard runs a disjoint subset in its own process, tests that secretly depend on shared external state — a database row, a fixed port, a global temp file — can collide when shards run at once, producing failures that never appeared when the suite ran serially. This is usually a latent bug the parallelism revealed rather than a sharding problem, and fixing the hidden shared state makes the suite both correct and parallelizable.

Isolation under sharding Shards run in separate processes, exposing shared-state bugs. disjoint shards separate processes shared state collides latent bug surfaces isolate resources cleanly parallelizable
Sharding pressure-tests isolation — collisions reveal latent shared-state bugs.

Order dependence is the related issue: a test that only passes because an earlier test in the same file left some state behind will fail when sharding splits them across processes. The fix is the same discipline that makes any test suite robust — each test sets up and tears down its own state, uses unique resources (a random port, a per-test temp directory, an isolated database schema), and does not rely on execution order. Sharding, by forcing tests into independent processes, is a useful pressure test for this isolation: a suite that shards cleanly is one whose tests are genuinely independent, which is valuable well beyond the CI speed-up sharding provides.

Frequently Asked Questions

How many shards should I use?

Enough that per-shard time approaches fixed overhead (checkout + install), but not so many that startup cost dominates. Four to eight is typical; measure and adjust.

Does sharding change test isolation?

No — each shard runs a disjoint subset in its own process, so tests remain isolated. Just avoid tests that depend on shared external state, which can collide across parallel runners.

How many shards should I use?

Enough that per-shard test time approaches the fixed overhead (checkout + install), but not so many that overhead dominates. Time the affected suite, divide by a two-to-three-minute target, and re-measure as the suite grows.

Why is one shard much slower than the others?

Framework sharding splits by file count, not runtime, so a shard that happens to hold the slow integration tests becomes the bottleneck. Isolate known-slow files or use timing-based partitioning to rebalance.

Should sharding come before or after affected detection?

After. Affected and caching reduce how much work exists; sharding parallelizes what remains. Shard the affected set, never the whole repo, or you pay N runners to run tests affected would have skipped.

How many shards should I use?

Enough that per-shard test time approaches the fixed checkout-and-install overhead, but not so many that overhead dominates. Time the affected suite, divide by a two-to-three-minute target, and re-measure as the suite grows.

Why is one shard much slower than the others?

Framework sharding splits by file count, not runtime, so a shard that happens to hold the slow integration tests becomes the bottleneck. Isolate known-slow files or use timing-based partitioning so slow tests are distributed rather than clustered.

Should sharding come before or after affected detection?

After. Affected and caching reduce how much work exists; sharding parallelizes what remains. Shard the affected set, never the whole repo, or you pay for parallelism on tests affected would have skipped.

Why do my tests pass serially but fail when sharded?

Usually a latent shared-state bug the parallelism exposed: tests that depend on a fixed port, a database row, or a global temp file collide when shards run at once. Give each test isolated resources and its own setup/teardown; a suite that shards cleanly is one whose tests are genuinely independent.

How do I gate a merge on all shards passing?

Add a single aggregating job that needs the whole shard matrix and succeeds only if they all did, then make that one job the required status check. With fail-fast: false, every shard runs to completion so one run surfaces all failures.

Related

CI/CD Pipeline Optimization for Monorepos