Build Times Are Developer Experience
Every minute added to a CI pipeline is a minute multiplied by every developer on your team, multiplied by every push they make per day. A 15-minute pipeline in a 50-person org running 200 builds per day costs 50 hours of wait time daily. That's not idle time — developers context-switch during builds, and the cost of returning to the original task after the build completes adds another 10-15 minutes per switch. Fast builds are a productivity multiplier that compounds across the entire organization.
Build Cache Strategies
Caching is the highest-leverage optimization for most pipelines. A clean build compiles everything from scratch. A cached build reuses artifacts from previous runs — dependencies, compiled objects, container layers, test fixtures. The difference is often 10x.
Dependency Caching
Every CI system supports caching dependency directories. GitHub Actions has actions/cache, GitLab CI has the cache directive, and CircleCI has save_cache/restore_cache. The cache key should include the lockfile hash so it invalidates only when dependencies actually change.
# GitHub Actions - effective dependency caching
- name: Cache node_modules
uses: actions/cache@v4
with:
path: |
node_modules
~/.cache/Cypress
key: deps-${{ hashFiles('package-lock.json') }}
restore-keys: |
deps-
- name: Cache Go modules
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: go-${{ hashFiles('go.sum') }}-${{ github.ref }}
restore-keys: |
go-${{ hashFiles('go.sum') }}-
go-
The restore-keys fallback is important. Without it, a cache miss on the exact key means a full clean install. With fallback keys, you get a partial cache hit — last week's dependencies plus a few new ones to install is still much faster than starting from zero.
Container Layer Caching
Docker builds benefit enormously from layer caching. The trick is structuring your Dockerfile so that layers that change frequently (your application code) are at the bottom, and layers that change rarely (OS packages, runtime dependencies) are at the top.
# Optimized Dockerfile for cache efficiency
FROM node:20-slim AS base
# Layer 1: system packages (changes rarely)
RUN apt-get update && apt-get install -y --no-install-recommends \
tini \
&& rm -rf /var/lib/apt/lists/*
# Layer 2: dependency declaration (changes when deps change)
WORKDIR /app
COPY package.json package-lock.json ./
# Layer 3: dependency installation (cached unless lock changes)
RUN npm ci --production
# Layer 4: application code (changes every build)
COPY . .
# Layer 5: build step
RUN npm run build
ENTRYPOINT ["tini", "--"]
CMD ["node", "dist/server.js"]
For CI environments, Docker BuildKit's --cache-from flag lets you pull cache layers from a registry rather than relying on the build machine's local cache (which CI runners typically don't persist). Push your built image to the registry on every build, then reference it as a cache source on the next build.
# BuildKit cache with registry
docker buildx build \
--cache-from type=registry,ref=ghcr.io/myorg/myapp:cache \
--cache-to type=registry,ref=ghcr.io/myorg/myapp:cache,mode=max \
--tag ghcr.io/myorg/myapp:$SHA \
--push .
Test Parallelization
After caching, parallelization is the next biggest time saver. Most test suites run sequentially by default, but the work is naturally parallelizable — individual test files rarely depend on each other.
Splitting by Timing Data
Naive parallelization (split tests evenly across N runners) produces unbalanced shards. One shard gets the integration tests that take 3 minutes each, another gets unit tests that take 50ms. The pipeline finishes when the slowest shard finishes.
Timing-based splitting uses historical test duration data to distribute tests evenly by total runtime. CircleCI and some other tools support this natively. For GitHub Actions, you'll need to collect timing data yourself and pass it to your test runner's parallelization feature.
# Jest parallel with timing-based shard distribution
# .github/workflows/test.yml
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- name: Run tests (shard ${{ matrix.shard }}/4)
run: |
npx jest --shard=${{ matrix.shard }}/4 \
--ci --forceExit --maxWorkers=2
Pipeline Architecture Patterns
A well-structured pipeline separates fast feedback from thorough validation. Developers need quick signals — lint errors, type-check failures, unit test failures should surface within 2 minutes. Slow validation (integration tests, E2E tests, security scans) runs in parallel but doesn't block the fast-feedback signal.
# GitLab CI - staged pipeline with fast feedback
stages:
- quick-check # < 2 min
- build # 2-5 min
- test # parallel, 5-10 min
- deploy # gated
lint-and-typecheck:
stage: quick-check
script:
- npm run lint
- npm run typecheck
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
unit-tests:
stage: quick-check
script:
- npm run test:unit -- --ci
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
build-image:
stage: build
script:
- docker buildx build --cache-from ... --push .
needs: [lint-and-typecheck]
integration-tests:
stage: test
parallel: 3
script:
- npm run test:integration -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
needs: [build-image]
e2e-tests:
stage: test
script:
- npm run test:e2e
needs: [build-image]
allow_failure: true # don't block on flaky E2E
Reducing Unnecessary Builds
Not every change needs every check. A documentation-only PR doesn't need integration tests. A change to the billing service doesn't need to run payment-gateway E2E tests. Path-based filtering runs only the checks relevant to the changed files.
Monorepo setups benefit the most from this approach. Without path filtering, every PR triggers builds for every package in the repo. With it, a change to packages/auth triggers only the auth package's tests and the tests of packages that depend on it.
Turborepo, Nx, and Bazel all provide dependency-aware task execution that understands which packages are affected by a change. For simpler setups, GitHub Actions' paths filter on workflow triggers gets you 80% of the benefit with much less configuration.
Runner Infrastructure
Self-hosted runners can be significantly faster than hosted runners if you right-size them. GitHub's hosted runners are 2-core machines with 7GB RAM. A self-hosted runner on an 8-core machine with NVMe storage and a warm dependency cache can cut build times by 60-70% for compute-heavy builds like Rust or C++ compilation.
The operational cost of self-hosted runners is the tradeoff. You need to manage the fleet, handle security patching, deal with ephemeral vs persistent runners, and implement auto-scaling. Tools like Actions Runner Controller (for Kubernetes) and Philips' terraform-aws-github-runner automate the fleet management, but they add infrastructure complexity.
For most teams, the right answer is hosted runners for lightweight jobs (lint, typecheck, unit tests) and self-hosted runners for heavy jobs (Docker builds, integration tests, compilation). Don't over-optimize — a 5-minute pipeline on hosted runners is fast enough for most workflows, and the operational simplicity is worth the slightly slower builds.
Flaky Test Management
Flaky tests are the silent killer of CI pipeline trust. A test that fails 5% of the time causes developers to re-run the entire pipeline, doubling wall-clock time on average. Worse, it trains developers to ignore test failures — "oh that test is always flaky, just re-run it" becomes the default response, and real failures slip through.
Track flake rates per test. Most CI systems don't do this natively, but you can build it from test result data. Flag any test that has failed and then passed on retry within the last 30 days. Once identified, quarantine flaky tests — move them to a separate job that runs but doesn't block the pipeline. Fix them within a sprint, or delete them. A quarantined test that stays quarantined for months is providing no value and should be removed.
Automatic retry is a band-aid, not a solution. Retrying failed test suites masks flakiness and doubles pipeline time for every flaky run. If you must retry, retry individual tests (not the entire suite) and track which tests needed retries. The retry data becomes your flake-rate input.
Pipeline as Code Best Practices
Keep pipeline definitions in the repository they build. A central pipeline repository that contains CI definitions for all services creates a coupling bottleneck — changes to one service's pipeline risk affecting others, and the platform team becomes a gatekeeper for CI changes.
Shared pipeline logic should live in reusable components: GitHub Actions composite actions, GitLab CI templates, or CircleCI orbs. Each service's pipeline file imports the shared components and configures them for its specific needs. This gives teams autonomy to modify their pipeline while maintaining organizational standards through the shared components.
Version your shared pipeline components semantically. A breaking change to a shared action should require teams to explicitly upgrade, not break their next build. Pin shared component versions in service pipelines and use Dependabot or Renovate to propose upgrades — the same way you'd manage library dependencies.