CI/CD Pipeline Optimisation That Developers Actually Thank You For
Most CI/CD pipelines optimise for machine throughput and ignore the human waiting on the other end. This post covers the pipeline engineering decisions — caching strategies, test parallelisation, monorepo diff-awareness, deployment staging — that actually improve developer velocity without sacrificing reliability.
# CI/CD Pipeline Optimisation That Developers Actually Thank You For
The average developer spends 47 minutes a day waiting for CI. That's not an estimate — it's a conservative number from a 2024 DX survey, and it hasn't gotten better. What's worse is that most of that time is wasted on builds and tests that should have been optimised months ago but weren't, because pipeline improvements are invisible work.
When was the last time your team celebrated a CI speedup? Probably never. Yet the same team will spend hours debating lint rules or commit message formats.
I've built and rebuilt pipelines across a dozen codebases — from monorepos with 500+ packages to tiny polyglot microservices to sprawling enterprise monoliths. The patterns that actually move the needle on developer happiness aren't about choosing GitHub Actions over GitLab CI. They're about caching strategy, dependency graph awareness, test parallelisation wisdom, and the deployment model that doesn't make people dread Friday afternoon.
This post covers each one with real config and code.
The Optimisation Hierarchy
Most teams optimise in the wrong order. They jump into "which runner image is fastest?" before answering "are we even running the right jobs?"
Here's the priority order that actually matters:
| Priority | Optimisation | Typical Gain | Effort |
|---|---|---|---|
| 1 | Stop running irrelevant jobs (diff-aware pipelines) | 60-80% pipeline time reduction on small PRs | Low |
| 2 | Cache everything, cache correctly | 40-60% build time reduction | Medium |
| 3 | Parallelise by test size, not by file | 30-50% test time reduction | Medium |
| 4 | Fail fast and fail loudly | 80% fewer "wait 20 minutes for lint to fail" | Low |
| 5 | Optimise runner boot time | 10-20% reduction | High |
Work top to bottom. If you skip #1 and #2, #3 and #4 are wasting money on stuff you shouldn't be running at all.
Step 1: Diff-Aware Pipelines Are Non-Negotiable
If your CI/CD pipeline runs the full test suite on a PR that only changes a README, you're burning money and developer goodwill. This sounds obvious, yet I regularly audit pipelines where a typo fix in docs/ triggers a 25-minute build, 15-minute test run, and E2E suite that opens a browser.
Mono-repo (Nx, Turborepo, Bazel)
If you're using a build system with native dependency graph awareness, the hard work is done:
# .github/workflows/ci.yml — Turborepo example
name: CI
on:
pull_request:
branches: [main]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for Turborepo's filter
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4
- name: Cache Turborepo
uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-
- name: Run affected tests
run: pnpm turbo run test --filter="[HEAD^1]"
# ^ Only runs tests for packages changed in the last commit
The --filter="[HEAD^1]" trick is where the magic lives. Turborepo compares the current workspace state against the specified ref and only runs tasks for packages that changed (plus their dependents). On a monorepo with 200 packages, this reduces a 40-minute pipeline to 4 minutes for most PRs.
But what if your commit history is messy and HEAD^1 doesn't capture the full PR diff? The robust version:
- name: Find PR base branch
id: pr-base
run: |
BASE=$(gh pr view ${{ github.event.pull_request.number }} --json baseRefName -q '.baseRefName')
echo "base=$BASE" >> $GITHUB_OUTPUT
- name: Run affected tests against base
run: pnpm turbo run test --filter="[...${{ steps.pr-base.outputs.base }}]"
``$
### Polyglot microservices
Without a monorepo tool, implement a simple diff-skipping logic:yaml
# Only run this service's pipeline if its source changed
jobs:
changes:
runs-on: ubuntu-latest
outputs:
service-a: ${{ steps.filter.outputs.service-a }}
service-b: ${{ steps.filter.outputs.service-b }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
service-a:
- 'services/service-a/**'
- 'libs/shared/**'
service-b:
- 'services/service-b/**'
- 'libs/shared/**'
deploy-service-a:
needs: changes
if: ${{ needs.changes.outputs.service-a == 'true' }}
runs-on: ubuntu-latest
steps:
- run: echo "Deploying service-a..."
**My opinion:** If your CI pipeline runs every test on every commit and you have more than 5 packages or services, you're bleeding thousands of developer-hours per year. Install a diff-aware build system or add path filtering. This week.
---
## Step 2: Cache Strategically, Not Greedily
The most common caching mistake: caching `node_modules` (or equivalent) as a single blob. It works until it doesn't — one package changes, the cache key misses, and you're back to a cold install. The second most common mistake: caching everything forever, consuming gigabytes of cache storage and slowing restore times.
**The correct approach:** cache at the lockfile level, not at the file-tree level. And for compiled artifacts, use tiered caching.
### Dependency caching (the right way)yaml
- name: Cache npm dependencies
with:
path: |
~/.npm
node_modules/.cache
key: npm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
npm-${{ runner.os }}-
`$
Key details:
- hashFiles('pnpm-lock.yaml')
— Changes only when the actual dependency tree changes. A code change doesn't invalidate the cache. - Cache ~/.npm
(global npm cache) instead ofnode_modules. Restoration is faster because npm/pnpm can rehydrate from the global cache rather than downloading from the registry. - Keep cache size under 1GB. GitHub Actions cache has a 10GB limit per repo, and oversized caches take longer to restore than they save.
Build artifact caching for compiled languages
# Rust — cache cargo registry + target directory
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
# Go — build cache is built in, just mount it
- uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
The cache invalidation trap
The hardest cache problem: you update a base library and all downstream services need to rebuild, but the build cache tells them "nothing changed" because the cache key hasn't changed.
Fix: Include the dependency graph hash in your cache key. With Nx or Turborepo, this is automatic. Without it, use a hash of your package.json dependencies + devDependencies + the lockfile:
- name: Compute dependency hash
id: dep-hash
run: |
HASH=$(node -e "
const pkg = require('./package.json');
const deps = JSON.stringify({...pkg.dependencies, ...pkg.devDependencies});
const crypto = require('crypto');
console.log(crypto.createHash('sha256').update(deps).digest('hex').slice(0, 12));
")
echo "hash=$HASH" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: node_modules/.cache
key: build-${{ runner.os }}-${{ steps.dep-hash.outputs.hash }}
Step 3: Parallelise by Test Size, Not by Test File
"Let's parallelise the test suite!" Most teams split tests by file and call it a day. This works until one test file takes 10 minutes and the other 99 take 2 minutes combined. Now your "parallel" test suite still waits 10 minutes.
The correct model: group tests by expected runtime and distribute evenly. This is what Jest calls "split by shard with timing data" and what Bazel calls "test sharding with historic metrics."
With Jest or Vitest
# Step 1: Collect test timing data
npx jest --json --outputFile=jest-times.json
# This produces timing per test file
# Step 2: Use the timing data for subsequent runs
npx jest --shard=1/4 --testPathPattern='src/**/*.test.ts'
On GitHub Actions, this becomes:
test:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- name: Restore timing data
uses: actions/cache@v4
with:
path: jest-times.json
key: jest-times-${{ hashFiles('src/**/*.test.ts') }}
- name: Run tests (shard ${{ matrix.shard }})
run: npx jest --shard=${{ matrix.shard }}/4
Timing data is cacheable. As long as you're not adding huge new test files between runs, the previous run's timing distribution is a reliable guide for the next run. Store it as a CI artifact or cache entry.
With Bazel
Bazel natively distributes tests across workers using its own scheduling. But the key insight is the same — shard by time, not by file count:
# BUILD.bazel
py_test(
name = "my_tests",
srcs = glob(["test_*.py"]),
shard_count = 10, # Explicit shard count
deps = [...],
)
Bazel will run each shard on a separate worker when you use
--jobs=10. The sharding splits the test resources (the *_test.py files) by count, which isn't perfect, but Bazel's historic timing data (--experimental_remote_cache_compression + remote execution) improves this over time.
When not to parallelise
There's a trap hidden here: too much parallelism burns money and gains nothing. If your test suite takes 3 minutes wall-clock, splitting into 8 parallel workers saves you maybe 90 seconds while costing 8x the runner time. The ROI is terrible.
Total test time Current setup After 4x paralellisation Runner-cost multiplier Worth it? 3 min 1 runner ~1 min 4x No 10 min 1 runner ~3 min 4x Maybe 30 min 1 runner ~8 min 4x Yes 60 min 1 runner ~16 min 4x Absolutely
The rule: don't parallelise below a 10-minute wall-clock test time. Spend that optimisation budget on caching or diff-awareness instead.
Step 4: Fail Fast, Not Fair
The standard CI pipeline is a waterfall: lint → typecheck → unit tests → integration tests → build → deploy. If lint fails, you wait for typecheck to also fail, then unit tests, then…
This is maddening. Developers sit on their hands waiting for 15 minutes to find out they forgot a semicolon.
The fix: Run fast feedback jobs first and gate the rest on them.
jobs:
# Fast feedback — runs immediately, fails fast
fast-feedback:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm run test:unit -- --changedSince=HEAD^1
# Slow feedback — runs in parallel after fast feedback passes
build-and-integration:
needs: [fast-feedback]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- run: npm run test:integration
# E2E — runs only after everything else passes
e2e:
needs: [build-and-integration]
runs-on: ubuntu-latest
steps:
- run: npm run test:e2e
The developer experience improvement is dramatic:
- Before: Push → wait 12 minutes → see lint fail on line 3. Fix → push → wait 12 more minutes.
- After: Push → wait 90 seconds → see lint fail on line 3. Fix → push → 90 seconds green.
This isn't just about speed — it's about feedback latency. A human's attention degrades after about 2 minutes of waiting. A 12-minute CI run means the developer has already context-switched to Slack or another ticket. They're not thinking about the lint error anymore. They have to re-load the mental context.
90 seconds fits within the "attention retention window." The test result arrives while they're still looking at the terminal.
Step 5: The Deployment Model That Doesn't Suck
CI is about confidence. CD is about courage. If your deployment pipeline makes developers anxious, they'll batch changes until Friday and then all hell breaks loose.
The deployment model that optimises for developer experience has three properties:
- Every commit should be deployable (trunk-based development)
- Deployment should be a button or automated commit-tag, not a ceremony
- Rollback should be faster than deploy (blue-green or canary, not re-deploy old code)
The deployment pipeline I actually like
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
concurrency:
group: production
cancel-in-progress: false # Don't cancel — queue instead
jobs:
build:
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.build.outputs.image-tag }}
steps:
- uses: actions/checkout@v4
- name: Build Docker image
id: build
run: |
TAG=${{ github.sha }}
docker build -t myapp:$TAG .
echo "image-tag=$TAG" >> $GITHUB_OUTPUT
deploy-canary:
needs: [build]
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy to canary (2% traffic)
run: |
kubectl set image deployment/myapp-canary myapp=myapp:${{ needs.build.outputs.image-tag }}
kubectl rollout status deployment/myapp-canary --timeout=5m
observe:
needs: [deploy-canary]
runs-on: ubuntu-latest
steps:
- name: Observe for 10 minutes
run: |
echo "Watching error rates and p99 latency for 10 minutes..."
sleep 600 # In reality, you'd check metrics
# If all good → promote
# If errors → abort
promote:
needs: [observe]
runs-on: ubuntu-latest
steps:
- name: Promote to full rollout
run: |
kubectl set image deployment/myapp myapp=${{ needs.build.outputs.image-tag }}
kubectl rollout status deployment/myapp --timeout=10m
``$
**Key design decisions here:**
- **`concurrency.cancel-in-progress: false`** — This queues deployments rather than cancelling. If two commits land close together, both deploy. Cancelling means the first commit might be half-deployed when it gets killed.
- **Canary then promote** — The canary runs for a fixed observation window. This catches issues that unit tests never will (memory leaks under real traffic, integration failures, dependency version mismatches).
- **Environment protection** — The `environment: production` block ensures that deployment requires approval if you set it up that way, but for fast-moving teams, you can make it auto-approve.
---
## Step 6: The CI/CD Dashboard (The One That Matters)
Teams love building elaborate CI/CD dashboards with DORA metrics — deployment frequency, lead time, mean time to recovery, change failure rate. These are useful for management. They're not useful for developers.
**The one dashboard that actually improves developer experience:** a simple "how long until CI finishes" live tracker.
bash
#!/bin/bash
# scripts/ci-eta.sh — Run locally to see ETA
# Queries GitHub API for the latest workflow run
WORKFLOW_ID=$1
BRANCH=$2
gh run view $WORKFLOW_ID --json jobs,status,createdAt --jq '
.jobs[]
| select(.status != "completed")
| "(.name): (.startedAt)//(.steps|length) steps"'
``
Or, better yet, integrate with Slack so developers get:
- 🔵 CI started (estimated 3 min)
- 🟢 CI passed (1 min feedback)
- 🔴 CI failed (lint at line 3 — "expected ';' at column 12")
The tool doesn't matter. The pattern does: surface the feedback where the developer is already looking, not in a separate dashboard they have to open.
What a Well-Optimised CI/CD Pipeline Feels Like
A developer pushes code. Within 30 seconds, they see:
- Lint passed
- Type checker passed
- Unit tests for the changed packages passed
They can already merge if they want. But there's more running in the background:
- Integration tests (3 minutes)
- Build + containerise (2 minutes)
- Deploy to staging (1 minute)
- E2E tests against staging (5 minutes)
- Deploy to production canary (1 minute)
- Observability check (5 minutes)
The developer doesn't wait for steps 4-9. They open a PR, write the description, ask for review. By the time the reviewer looks, all 9 steps are green and the deploy is live.
Total developer wait time: ~30 seconds. Total machine time: ~18 minutes.This is the inversion most teams get wrong: they optimise CI/CD for machine throughput (how fast can we process all this work?) instead of human throughput (how fast can the developer get actionable feedback?).
Key Takeaways
- Diff-awareness is the highest-ROI optimisation. Don't run what didn't change. Use Nx, Turborepo, Bazel, or path filters.
- Cache at the lockfile level, not the filetree level. Include dependency graph hashes for compiled-language builds.
- Parallelise by test time, not test count. Use historic timing data. Don't parallelise suites under 10 minutes — the cost isn't worth it.
- Fail fast. Run lint and type checks first. Gate slower jobs behind faster ones.
- Deploy every commit, but canary first. Queue deployments rather than cancelling. Never deploy straight to 100%.
- Push feedback to where developers already are. Slack, terminal, or PR checks — not a separate dashboard.
- Optimise for developer attention span, not machine utilization. The human waiting on the pipeline is the scarce resource. Everything else follows.
The best CI/CD pipeline is the one developers don't notice. When it's fast enough to be invisible, reliable enough to be trusted, and feedback-rich enough to not need a separate investigation — that's when you've actually optimised. Not when the dashboard shows green numbers.