Optimizing CI Pipelines: Test Parallelization Strategies for Large Codebases
A large codebase accumulates tests the same way it accumulates technical debt — gradually, then suddenly. A test suite that took 3 minutes a year ago now takes 25 minutes, and nobody can pinpoint when it crossed the threshold from "fast enough" to "everyone waits for CI." Parallelization can bring that 25-minute pipeline back under 5 minutes, but only if you apply the right strategies in the right order.
This guide covers the full parallelization stack: measuring where time actually goes, splitting tests by type into parallel jobs, dynamic sharding based on historical durations, running only tests affected by a change, and making caching work for you instead of against you. Each section includes concrete configuration for GitHub Actions, CircleCI, and GitLab CI.
Step 1: Measure Where Time Is Spent
Before changing anything, understand what's actually slow. Most teams assume their E2E tests are the bottleneck and are correct — but often unit tests have grown to 5,000 cases and now take longer than the E2E suite.
Profiling with Jest
# Get per-file timing
jest --json --outputFile=jest-results.json 2>/dev/null
# Extract and sort by duration
node -e "
const r = require('./jest-results.json');
const files = r.testResults.map(t => ({
file: t.testFilePath.replace(process.cwd(), ''),
duration: t.perfStats.runtime,
tests: t.numPassingTests + t.numFailingTests,
})).sort((a, b) => b.duration - a.duration);
console.log('Top 20 slowest test files:');
files.slice(0, 20).forEach(f =>
console.log(\`\${f.duration}ms\t\${f.tests} tests\t\${f.file}\`)
);
console.log(\`\nTotal: \${files.reduce((s, f) => s + f.duration, 0)}ms\`);
console.log(\`Files: \${files.length}\`);
"Profiling with Vitest
# Vitest outputs timing to stdout with --reporter=verbose
vitest run --reporter=verbose 2>&1 | grep -E "✓|×|↓" | \
awk '{print $NF, $0}' | sort -rn | head -20Measuring CI Job Duration Breakdown
In GitHub Actions, use job summaries to understand where time goes:
- name: Record timings
run: |
echo "## Timing Report" >> $GITHUB_STEP_SUMMARY
echo "| Step | Duration |" >> $GITHUB_STEP_SUMMARY
echo "|---|---|" >> $GITHUB_STEP_SUMMARY
echo "| npm ci | ${{ steps.npm-ci.outputs.duration }}s |" >> $GITHUB_STEP_SUMMARY
echo "| Unit tests | ${{ steps.unit.outputs.duration }}s |" >> $GITHUB_STEP_SUMMARY
echo "| Integration tests | ${{ steps.integration.outputs.duration }}s |" >> $GITHUB_STEP_SUMMARYA simpler approach: look at the GitHub Actions job timeline view. The timeline shows each step as a bar — the longest bars are your targets.
Step 2: Split Tests by Type into Parallel Jobs
The highest-leverage parallelization for most codebases is splitting test types into separate jobs that run simultaneously. Unit tests, integration tests, and E2E tests have different dependencies, different runtimes, and different failure characteristics. There's no reason to run them sequentially.
GitHub Actions: Parallel Jobs by Type
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: jest --testPathPattern='\.unit\.test\.' --maxWorkers=4
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
cache: 'npm'
- run: npm ci
- run: jest --testPathPattern='\.integration\.test\.' --maxWorkers=2
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
e2e-tests:
name: E2E Tests
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- run: npx playwright test --shard=${{ matrix.shard }}/3
lint-and-types:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
cache: 'npm'
- run: npm ci
- run: npm run lint & npm run type-check & wait # Run both in parallel within the jobAll four jobs — unit tests, integration tests, E2E tests (sharded 3 ways), and lint/types — run simultaneously. The total pipeline duration is determined by the slowest job, not the sum of all jobs.
CircleCI: Parallel Jobs by Type
# .circleci/config.yml
version: 2.1
orbs:
node: circleci/node@5.2
executors:
default:
docker:
- image: cimg/node:20.12
resource_class: medium # 2 vCPUs, 4GB RAM
jobs:
unit-tests:
executor: default
steps:
- checkout
- node/install-packages
- run:
name: Unit tests
command: jest --testPathPattern='\.unit\.' --maxWorkers=2
integration-tests:
executor: default
docker:
- image: cimg/node:20.12
- image: cimg/postgres:16.2
environment:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
steps:
- checkout
- node/install-packages
- run:
name: Wait for Postgres
command: dockerize -wait tcp://localhost:5432 -timeout 1m
- run:
name: Integration tests
command: jest --testPathPattern='\.integration\.' --maxWorkers=1
e2e-tests:
executor: default
parallelism: 4 # CircleCI native parallelism
steps:
- checkout
- node/install-packages
- run: npx playwright install chromium
- run:
name: E2E tests
command: |
# Use CircleCI's built-in test splitting
TESTS=$(circleci tests glob "tests/e2e/**/*.spec.ts" | \
circleci tests split --split-by=timings)
npx playwright test $TESTS
workflows:
ci:
jobs:
- unit-tests
- integration-tests
- e2e-testsCircleCI's parallelism key and circleci tests split --split-by=timings automatically balance tests across parallel containers using stored timing data from previous runs.
GitLab CI: Parallel Jobs by Type
# .gitlab-ci.yml
stages:
- test
variables:
NODE_VERSION: "20"
.node-base:
image: node:20-slim
cache:
key: $CI_COMMIT_REF_SLUG
paths:
- node_modules/
policy: pull-push
before_script:
- npm ci
unit-tests:
extends: .node-base
stage: test
script:
- jest --testPathPattern='\.unit\.' --maxWorkers=4
coverage: '/Statements\s*:\s*(\d+\.?\d*)%/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
integration-tests:
extends: .node-base
stage: test
services:
- name: postgres:16
alias: postgres
variables:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
DATABASE_URL: postgresql://test:test@postgres:5432/testdb
script:
- jest --testPathPattern='\.integration\.' --maxWorkers=2
e2e-tests:
extends: .node-base
stage: test
parallel: 4 # GitLab native parallel jobs
script:
- npx playwright install chromium
- npm run build
- npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
artifacts:
when: always
paths:
- playwright-report/
expire_in: 1 weekStep 3: Dynamic Sharding Based on Historical Durations
Static sharding by file count leaves slow shards as bottlenecks. Dynamic sharding uses timing data from previous runs to create balanced groups.
Storing and Using Timing Data
# GitHub Actions — store timing data as artifact
jobs:
test:
steps:
- name: Download previous timing data
uses: actions/cache@v4
with:
path: .test-timings.json
key: test-timings-${{ github.ref }}
restore-keys: test-timings-
- name: Run tests with timing output
run: |
jest --json --outputFile=jest-results.json
# Extract timings for next run
node scripts/extract-timings.js jest-results.json > .test-timings.json
- name: Cache timing data
uses: actions/cache@v4
with:
path: .test-timings.json
key: test-timings-${{ github.ref }}// scripts/shard-by-timing.js
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const SHARD_COUNT = parseInt(process.env.SHARD_COUNT || '5');
const SHARD_INDEX = parseInt(process.env.SHARD_INDEX || '1') - 1; // 0-based
// Load timing data or fall back to equal split
let timings = {};
try {
timings = JSON.parse(fs.readFileSync('.test-timings.json', 'utf-8'));
} catch {
console.error('No timing data found, using equal distribution');
}
// Get all test files
const allFiles = execSync('jest --listTests', { encoding: 'utf-8' })
.trim()
.split('\n');
// Assign durations (default to median for unknown files)
const durations = allFiles.map(f => ({
file: f,
duration: timings[f] || median(Object.values(timings)),
}));
// Sort descending by duration (helps with bin-packing)
durations.sort((a, b) => b.duration - a.duration);
// Greedy bin-packing into shards
const shards = Array.from({ length: SHARD_COUNT }, () => ({ files: [], total: 0 }));
for (const item of durations) {
const minShard = shards.reduce((min, s, i) =>
s.total < shards[min].total ? i : min, 0);
shards[minShard].files.push(item.file);
shards[minShard].total += item.duration;
}
// Output files for the requested shard
const myFiles = shards[SHARD_INDEX].files;
console.log(myFiles.join('\n'));
function median(values) {
if (!values.length) return 30000; // default 30s
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
}# Use in GitHub Actions
- name: Run tests for my shard
run: |
FILES=$(SHARD_COUNT=5 SHARD_INDEX=${{ matrix.shard }} \
node scripts/shard-by-timing.js | tr '\n' ' ')
jest $FILESStep 4: Test Impact Analysis — Only Run Affected Tests
For large codebases, the fastest tests are the ones you don't run. Test impact analysis maps source files to the tests that cover them, so a change to src/auth/login.ts only runs tests/auth/*.test.ts and any other tests that import login.ts.
Jest with --findRelatedTests
# Find and run only tests related to changed files
CHANGED=$(git diff --name-only origin/main | grep -E '\.(ts|tsx|js|jsx)$')
jest --findRelatedTests $CHANGEDIn GitHub Actions:
- name: Get changed files
id: changes
run: |
CHANGED=$(git diff --name-only origin/${{ github.base_ref }} | \
grep -E '\.(ts|tsx|js|jsx)$' | tr '\n' ' ')
echo "files=$CHANGED" >> $GITHUB_OUTPUT
- name: Run affected tests
run: |
if [ -n "${{ steps.changes.outputs.files }}" ]; then
jest --findRelatedTests ${{ steps.changes.outputs.files }}
else
echo "No JS/TS files changed, skipping tests"
fiAlways Run Full Suite on Main
Test impact analysis is for PR builds only. On main, run the full suite:
- name: Run tests
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
jest # Full suite
else
CHANGED=$(git diff --name-only origin/main | grep -E '\.(ts|tsx)$' | tr '\n' ' ')
if [ -n "$CHANGED" ]; then
jest --findRelatedTests $CHANGED
else
jest --passWithNoTests
fi
fiVitest with --related
# Run only tests covering changed files
vitest run --changed origin/main
# Or specify files explicitly
vitest run --related src/auth/login.ts src/checkout/cart.tsStep 5: Caching Strategies
Cache misses can add 2-5 minutes to each CI job. Effective caching is as important as parallelization.
Node Modules Caching
# GitHub Actions — optimal cache key
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# Automatically caches ~/.npm keyed on package-lock.json hash
# For workspaces/monorepos
- uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
packages/*/node_modules
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-Build Output Caching
If your tests require a build step (e.g., TypeScript compilation, Next.js build):
- name: Cache Next.js build
uses: actions/cache@v4
with:
path: |
.next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('**/*.ts', '**/*.tsx', '**/package-lock.json') }}
restore-keys: nextjs-${{ runner.os }}-
- name: Build
run: npm run build # Uses cached .next/cache if availableTest Result Caching (Skip Unchanged Tests)
For unit tests, you can skip tests whose source files haven't changed:
- name: Cache Jest results
uses: actions/cache@v4
with:
path: .jest-cache
key: jest-${{ runner.os }}-${{ hashFiles('src/**', 'tests/**') }}
restore-keys: jest-${{ runner.os }}-
- name: Run tests
run: jest --cache --cacheDirectory=.jest-cache
# Jest skips re-running tests when inputs haven't changedBringing It All Together: A Real-World Pipeline
Here's a complete pipeline for a monorepo with multiple packages:
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
outputs:
api: ${{ steps.filter.outputs.api }}
web: ${{ steps.filter.outputs.web }}
shared: ${{ steps.filter.outputs.shared }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
api:
- 'packages/api/**'
web:
- 'packages/web/**'
shared:
- 'packages/shared/**'
api-tests:
needs: changes
if: needs.changes.outputs.api == 'true' || needs.changes.outputs.shared == 'true'
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3]
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
options: --health-cmd pg_isready --health-interval 5s
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
cache: 'npm'
- run: npm ci
- run: |
cd packages/api
jest --shard=${{ matrix.shard }}/3 --maxWorkers=2
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
web-unit-tests:
needs: changes
if: needs.changes.outputs.web == 'true' || needs.changes.outputs.shared == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
cache: 'npm'
- run: npm ci
- run: cd packages/web && jest --maxWorkers=4
web-e2e-tests:
needs: [web-unit-tests]
if: needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
cache: 'npm'
- run: npm ci
- name: Cache Playwright
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('package-lock.json') }}
- run: npx playwright install chromium
- run: npm run build
- run: npx playwright test --shard=${{ matrix.shard }}/4
- uses: actions/upload-artifact@v4
if: always()
with:
name: pw-blob-${{ matrix.shard }}
path: blob-report
merge-e2e-reports:
needs: web-e2e-tests
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
cache: 'npm'
- run: npm ci
- uses: actions/download-artifact@v4
with:
pattern: pw-blob-*
merge-multiple: true
path: all-blobs
- run: npx playwright merge-reports --reporter=html ./all-blobs
- uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-reportMeasuring the Impact
Track pipeline duration over time to validate that optimizations are working. Store timing data in a structured format and alert when it regresses:
// scripts/check-ci-budget.js
const MAX_PIPELINE_MINUTES = 10;
const actualMinutes = parseFloat(process.env.PIPELINE_DURATION_MINUTES);
if (actualMinutes > MAX_PIPELINE_MINUTES) {
console.error(
`CI pipeline took ${actualMinutes}m, exceeding budget of ${MAX_PIPELINE_MINUTES}m`
);
process.exit(1);
}Tools like HelpMeTest can aggregate test results across pipeline runs, showing you which tests are consistently slow, which are flaky, and where your optimization budget is best spent.
Summary
Optimizing a slow CI pipeline follows a clear sequence:
- Measure first — profile which files and test types are actually slow before changing anything
- Split by type — run unit, integration, and E2E tests as parallel jobs immediately; this is the highest-leverage change
- Shard within types — split large test types into multiple parallel jobs using duration-balanced sharding
- Test impact analysis — on PRs, only run tests affected by the changed files
- Cache aggressively — node_modules, build outputs, and Jest cache all have high ROI
Each strategy compounds. A codebase with 25-minute CI can typically reach 4-5 minutes by applying all five strategies. The work pays back within days for any team running more than a few CI builds per day.