Why CI Speed Matters
A 20-minute CI pipeline that runs 50 times a day is 16+ hours of developer waiting. When CI is fast, developers get feedback quickly, push smaller commits, and iterate faster.
These tricks have consistently delivered 50-80% CI time reductions.
1. Cache Dependencies
The single biggest win in most pipelines. Node modules, pip packages, cargo dependencies โ cache them between runs:
- name: Cache node modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
The hashFiles key means the cache is only invalidated when package-lock.json changes. Cache hits skip the install entirely.
2. Parallelize Jobs
Don't run tests after lint after type-check sequentially. Run them in parallel:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run lint
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npx tsc --noEmit
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
build:
needs: [lint, typecheck, test] # only runs if all pass
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
Wall time = longest job, not sum of all jobs.
3. Use actions/setup-node with Caching Built In
Many setup actions now have built-in caching:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # built-in npm cache
Simpler than managing the cache action manually.
4. Skip Unchanged Code with Path Filters
Don't run your whole test suite when only docs changed:
on:
push:
paths-ignore:
- '**.md'
- 'docs/**'
# Or only run specific jobs based on what changed:
jobs:
changes:
runs-on: ubuntu-latest
outputs:
api: ${{ steps.filter.outputs.api }}
frontend: ${{ steps.filter.outputs.frontend }}
steps:
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
api:
- 'src/api/**'
frontend:
- 'src/frontend/**'
test-api:
needs: changes
if: needs.changes.outputs.api == 'true'
runs-on: ubuntu-latest
steps:
- run: npm test -- --testPathPattern=api
5. Use Reusable Workflows
Stop copy-pasting the same CI steps across repositories:
# .github/workflows/shared-test.yml
on:
workflow_call:
inputs:
node-version:
required: false
type: string
default: '20'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- run: npm ci && npm test
# In any other repo:
jobs:
test:
uses: myorg/shared-workflows/.github/workflows/shared-test.yml@main
with:
node-version: '20'
6. Fail Fast, Cancel Redundant Runs
Cancel in-progress CI when a new commit is pushed to the same PR:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Add this at the top level of your workflow. No more 5 queued CI runs for a branch you're actively working on.
7. Use GitHub Actions Cache for Docker Layers
Building Docker images without layer caching is painfully slow:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myapp:${{ github.sha }}
cache-from: type=gha # Use GitHub Actions cache
cache-to: type=gha,mode=max # Write to GitHub Actions cache
The type=gha cache persists Docker layer cache between workflow runs, dramatically speeding up image builds when only source code changes.
Bonus: Monitor Your CI Times
- name: Report timing
if: always()
run: |
echo "Job duration: ${{ job.duration }}s" >> $GITHUB_STEP_SUMMARY
Track your CI times over time. If a job grows from 3 minutes to 15 minutes, you want to know when and why.
Key Takeaways
- Cache dependencies โ the single biggest speedup in most pipelines
- Parallelize jobs โ wall time = longest job, not sum of all
- Path filters โ skip unchanged code to skip unnecessary work
- Concurrency + cancel โ eliminate queued runs from rapid commits
- Docker layer cache โ use
type=ghafor build-push-action - Reusable workflows โ share CI logic across repositories without copy-paste