A clean CI run takes a few minutes. A slow one takes 20+ — and developers stop trusting it. This lesson covers the three biggest performance levers: caching, artifacts, and pipeline structure.
Caching
The single highest-impact optimisation is caching the dependency install step. Without caching, every CI run downloads node_modules, the pip cache, the Go module cache, the Maven cache, etc. With caching, you download only the diff.
Using actions/cache directly
- name: Cache npm
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
The cache key is the lockfile hash — when the lockfile changes, the cache misses (correctly) and a new entry is created. The restore-keys: prefix list lets a partial match restore an older cache as a starting point.
Built-in caching via setup actions
The official language setup actions wrap cache logic for you:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm' # restore + save npm cache, keyed on package-lock.json
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- uses: actions/setup-go@v5
with:
go-version: '1.23'
cache: true
Always prefer these built-ins over actions/cache when they exist. They get the key logic right.
Cache Behaviour
- Total cache storage per repository: 10 GB (LRU eviction beyond that)
- Caches are keyed per branch and per fork — a feature branch can't pollute main's cache
- Cache restore from main is allowed in PRs from the same repo (not forks)
- Unused caches expire after 7 days
Artifacts
Artifacts pass files from one job to another in the same workflow run:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 7
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- run: ./deploy.sh
Artifact vs Cache — when to use which
| Use case | Tool |
|---|---|
| Pass build output to a deploy job | Artifact |
Reuse node_modules between runs | Cache |
| Keep test reports for human review | Artifact |
| Speed up future builds with a Docker layer | Cache (or registry-based BuildKit cache) |
| Share a built container image | Push to a registry — neither |
Artifact Retention
Default 90 days. For chatty CI that produces large artifacts on every PR, shorten it:
- uses: actions/upload-artifact@v4
with:
name: test-results
path: junit/
retention-days: 14
Artifacts count against your account's storage quota — keeping them lean saves money.
Pipeline Structure
Caching shaves minutes; pipeline shape can shave half the wall-clock time. Decisions that compound:
Parallelise independent jobs
jobs:
lint: { runs-on: ubuntu-latest, steps: [...] }
type-check: { runs-on: ubuntu-latest, steps: [...] }
unit: { runs-on: ubuntu-latest, steps: [...] }
e2e: { runs-on: ubuntu-latest, steps: [...] }
# No needs: between them → they all start at once
If lint, type-check, unit, and e2e each take 4 minutes, sequentially the workflow takes 16 minutes; in parallel, 4.
Split slow jobs
A 12-minute test job that's CPU-bound is often a 3-minute job × 4 shards. Use a matrix variable for the shard index:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npm test -- --shard=${{ matrix.shard }}/4
Use path filters to skip unnecessary work
on:
push:
paths:
- 'src/**'
- 'package*.json'
- '.github/workflows/ci.yml'
Changes that only touch README.md shouldn't run the full test matrix.
Concurrency to cancel superseded runs
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
If a developer pushes three times in a minute, only the last push needs to fully run. The first two cancel.
Measuring
The Actions UI shows per-job and per-step duration. For a deeper view, the Insights → Actions tab (Enterprise) shows usage and time-to-feedback trends. There's also actions-runner-controller metrics for self-hosted fleets.
Practical Optimisation Order
- Enable language-setup cache (5-minute change, often saves minutes per run)
- Parallelise the obvious independent jobs
- Add path filters to skip irrelevant paths
- Add concurrency: cancel-in-progress for PR workflows
- Shard tests if a single job is still > 5 minutes
- Cache Docker layers via BuildKit's
--cache-to type=gha - Consider larger runners or self-hosted only after the above