Splitting Lint, Test and Build into Parallel Jobs
One ci job runs install, lint, test, then build, and takes eleven minutes. The lint failure that will fail the run happens at minute four โ but the developer finds out at minute eleven, because everything after it still ran. Splitting the stages into sibling jobs surfaces every failure at once and cuts wall-clock to the slowest single stage.
When to use this pattern
- Your CI is one long job whose stages do not actually depend on each other.
- Developers wait for the whole run to learn about a failure in its first stage.
- Dependency caching is already working, so the repeated setup cost is small.
Prerequisites
What actually depends on what
The reason the sequential job is slow is that it encodes dependencies that do not exist. Lint does not need the build. Unit tests do not need lint. Only the deploy needs the build output. Drawing the real dependency graph is the whole design step.
Note the small increases inside the fan-out: each job now does its own checkout and cache restore, so lint goes from 1m20 to 1m40. Total runner minutes rise by about a minute; wall-clock falls by more than five.
Complete working example
# .github/workflows/ci.yml
name: CI
on: [pull_request]
concurrency:
group: ci-${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
setup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
# Populates the cache keyed on the lockfile. The three jobs below restore it
# in ~15s instead of resolving and downloading the tree again.
- run: npm ci --ignore-scripts
lint:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci --ignore-scripts --prefer-offline
- run: npm run lint -- --max-warnings 0
test:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci --ignore-scripts --prefer-offline
- run: npm test -- --coverage
- uses: actions/upload-artifact@v4
if: always()
with: { name: coverage, path: coverage/, retention-days: 1 }
build:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci --ignore-scripts --prefer-offline
- run: npm run build
# The bundle is produced once and consumed by deploy โ never rebuilt downstream.
- uses: actions/upload-artifact@v4
with: { name: dist, path: dist/, retention-days: 7 }
ci-complete:
needs: [lint, test, build]
if: always()
runs-on: ubuntu-latest
steps:
- name: Require all three
run: |
results="${{ needs.lint.result }} ${{ needs.test.result }} ${{ needs.build.result }}"
echo "$results"
case "$results" in *failure*|*cancelled*) exit 1 ;; esacStep-by-step walkthrough
The setup job exists to warm the cache, not to share a filesystem. Jobs run on separate machines with separate disks, so node_modules does not travel between them. What travels is the cache entry keyed on the lockfile โ which is why every job repeats npm ci but only the first one pays full price.
--prefer-offline makes the repeat installs cheap. With a warm cache it resolves everything locally and skips the registry entirely, typically 12โ20 seconds against two minutes cold.
Upload the bundle, do not rebuild it. A deploy job that runs npm run build again is not deploying what CI tested โ it is deploying a second build that happens to come from the same commit. Producing the artifact once is also what makes provenance meaningful, as covered in generating build provenance attestations.
if: always() on the coverage upload. Coverage from a failing test run is exactly when you want to look at it. Without the condition, the upload is skipped precisely when it would have been useful.
One aggregate check, not three required checks. Requiring lint, test and build individually works until you rename one, at which point branch protection blocks on a check that no longer exists. A single ci-complete job insulates the protection rules from pipeline refactoring.
Verification
# 1. The jobs really do run concurrently โ compare start times.
gh run view --json jobs \
--jq '.jobs[] | "\(.startedAt) \(.completedAt) \(.name)"' | sort
# 2. Cache is being hit in the fan-out jobs (not just populated in setup).
gh run view --log --job build | grep -iE 'cache (restored|hit|saved)' | head
# 3. Wall-clock and total minutes, before and after the change.
gh run list --limit 20 --json databaseId,createdAt,updatedAt \
--jq '[.[] | ((.updatedAt|fromdateiso8601)-(.createdAt|fromdateiso8601))/60]
| { runs: length, median_minutes: (sort | .[length/2|floor] | .*10|round/10) }'Expected from the first command โ three overlapping windows after setup completes:
2026-07-31T09:00:04Z 2026-07-31T09:02:06Z setup
2026-07-31T09:02:10Z 2026-07-31T09:03:50Z lint
2026-07-31T09:02:11Z 2026-07-31T09:07:01Z test
2026-07-31T09:02:11Z 2026-07-31T09:05:41Z buildIf the three start times are staggered by minutes rather than seconds, the runner pool is the constraint rather than the pipeline shape, and splitting further will not help.
When the split does not pay
Fan-out is not free, and there is a size below which it makes things worse. Each job re-pays checkout and cache restore โ call it 25 seconds โ so three jobs add roughly 50 seconds of pure overhead relative to one. If lint, test and build together take 90 seconds, you have spent 50 seconds of billed time to save perhaps 20 of wall-clock, and made the run harder to read in the process.
The useful threshold is the ratio between the longest stage and the per-job overhead. When the slowest stage is at least four or five times the overhead, splitting is clearly worth it. When it is under two, keep the single job. In between, split only the stage that actually fails often โ usually tests โ and leave the rest sequential, which captures most of the feedback benefit for one extra job instead of three.
Common pitfalls
Expecting node_modules to survive between jobs. It does not. Restore from cache in each job, or upload it as an artifact โ and note that uploading node_modules is usually slower than a warm npm ci, because it is tens of thousands of small files.
Rebuilding in the deploy job. Two builds from one commit can differ, and the one you tested is not the one you shipped. Consume the uploaded artifact.
Splitting a stage that fails for the same reason as another. If lint and type-check always fail together, two jobs report the same problem twice and cost twice the overhead. Split along independent failure modes, not along tool names.
Related
- Designing Multi-Stage CI/CD Pipelines for React Apps โ the parent guide on pipeline structure.
- Parallelizing Test Suites in CI โ the next step once the test job is the critical path.
- Best Practices for Caching npm vs Yarn vs pnpm in CI โ what makes the repeated installs cheap.
- Cancelling Superseded Workflow Runs with Concurrency Groups โ keeps the extra jobs from multiplying wasted runs.
โ Back to Designing Multi-Stage CI/CD Pipelines for React Apps