Q39 of 48 · Cypress

How would you parallelise Cypress tests across CI machines without Cypress Cloud?

CypressSeniorcypressparallelcishardingsenior

Short answer

Short answer: Generate a list of spec files, split it into N shards based on historical duration (or by file count as a fallback), and pass each shard to a separate runner via `--spec`. Most CI providers (GitHub Actions matrix, CircleCI parallelism, Buildkite) make this straightforward.

Detail

Cypress Cloud's --parallel flag uses the cloud to coordinate which spec each runner picks up. Without it, you manage the splitting yourself. The two approaches:

1. By file count (simple). List all specs, split into N equal-size chunks, run each on its own machine.

SPECS=$(find cypress/e2e -name '*.cy.ts' | sort)
TOTAL=$(echo "$SPECS" | wc -l)
PER_SHARD=$(( (TOTAL + N - 1) / N ))
START=$(( (SHARD_INDEX - 1) * PER_SHARD ))
SHARD_SPECS=$(echo "$SPECS" | tail -n +$((START+1)) | head -n $PER_SHARD | paste -sd,)
npx cypress run --spec "$SHARD_SPECS"

This is fine if specs have roughly uniform duration. If durations vary 10x, you'll have one shard finishing in 2 minutes and another in 20.

2. By historical duration (recommended). Record each spec's wall time on the previous run, then bin-pack the next run so each shard finishes around the same time. Tools like cypress-grep's plugin or hand-rolled shell scripts work. A typical implementation:

  • After each successful CI run, post-process the JUnit XML to extract per-spec durations.
  • Store them in a shared location (S3, gh-pages, the repo itself).
  • Before the next run, read the file, sort specs by duration descending, and assign each to the currently-shortest shard (greedy bin-packing).

The bin-packing usually beats round-robin file assignment by 30-50% on heterogeneous suites.

3. CI provider integrations. GitHub Actions matrix strategy.matrix.shard: [1, 2, 3, 4] gives you the variable; pass it to your sharding script. CircleCI has circleci tests split --split-by=timings built in. Buildkite has buildkite-agent meta-data for coordinating durations.

4. Combine with retries and reports. Each shard publishes its own JUnit report; merge in a final job and surface the aggregate.

The trade-off vs Cypress Cloud: cloud auto-balances based on real-time progress (a slow runner picks up smaller specs); manual sharding pre-assigns and is less adaptive. But for a fixed-size suite where durations are stable, manual sharding is perfectly competitive — and it's free.

// EXAMPLE

.github/workflows/cypress.yml

name: cypress
on: [pull_request]

jobs:
  e2e:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4, 5, 6, 7, 8]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci

      - name: Compute shard specs (duration-balanced)
        id: split
        run: |
          # Pull historical timings from previous run
          curl -s -o timings.json https://ci-artifacts.example/timings/main.json
          # Run a small node script that bin-packs by duration
          SPECS=$(node scripts/shard.js \
            --shard ${{ matrix.shard }} \
            --total 8 \
            --timings timings.json)
          echo "specs=$SPECS" >> $GITHUB_OUTPUT

      - run: npx cypress run --spec "${{ steps.split.outputs.specs }}"

      - name: Upload report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: report-shard-${{ matrix.shard }}
          path: cypress/results/

// WHAT INTERVIEWERS LOOK FOR

Knowing both file-count and duration-based splitting, naming the bin-packing approach, and CI matrix integration. Bonus for the trade-off vs Cypress Cloud.

// COMMON PITFALL

Doing round-robin spec splitting and complaining about uneven shard durations — duration-based splitting solves it.