TL;DR:
- Dependency caching alone typically cuts 40–60% off install steps — it’s the highest-return single change you can make
- Matrix strategies let you run parallel test suites instead of sequential, cutting wall-clock time proportionally to parallelism
- Workflow concurrency controls and conditional steps stop you paying for work you don’t need
If your CI pipeline takes 15 minutes, and your team opens 20 PRs a day, that’s 5 hours of developer waiting time — before you count failures and reruns. Slow CI has a compounding cost that’s easy to underestimate because no individual wait feels catastrophic.
Most GitHub Actions pipelines have significant unused headroom. The techniques here are not obscure — they’re documented features that many teams never fully implement. Each section covers a specific technique with copy-pasteable YAML.
1. Dependency Caching (The Biggest Win)
The first thing most jobs do is install dependencies. Without caching, this runs from scratch on every job — downloading and installing the same packages repeatedly.
GitHub Actions provides a cache action that stores and restores directories between runs using a key derived from your lockfile:
- name: Cache node modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
The key is derived from package-lock.json hash — if the lockfile hasn’t changed, the cache hits. If it has changed (new dependency added), the cache misses and a fresh install runs.
For pnpm:
- uses: pnpm/action-setup@v4
with:
version: 10
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ~/.local/share/pnpm/store
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
For Python (pip/uv):
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-
Realistic impact: a Node.js project with a 500MB node_modules that takes 3–4 minutes to install cold will restore in 30–45 seconds from cache. On a pipeline that runs 50 times a day, this is around 2.5 hours of saved time.
2. Matrix Strategies for Parallel Testing
If you run a test suite sequentially, every test waits for the previous one to finish. Matrix strategies split work across parallel runners:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
test-suite: [unit, integration, e2e]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run test:${{ matrix.test-suite }}
This creates 6 parallel jobs (2 Node versions × 3 test suites). If each takes 5 minutes, the total wall-clock time is still 5 minutes — not 30.
Splitting a single large test suite:
If you have one large test suite rather than naturally separate suites, you can split by file range:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npx jest --shard=${{ matrix.shard }}/4
Vitest supports --shard similarly. A 20-minute test suite split into 4 shards becomes a 5-minute pipeline step.
Matrix exclusions and includes let you avoid running combinations that don’t make sense:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [20, 22]
exclude:
- os: windows-latest
node: 20
3. Concurrency Controls
By default, GitHub Actions queues every triggered run. If you push 5 times before a workflow finishes, you get 5 queued runs — all of which will eventually run, wasting minutes on commits that have already been superseded.
The concurrency key cancels in-progress runs when a new one starts for the same logical unit (branch + workflow):
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
On a branch with active development, this can eliminate 50–70% of wasted runs. The only exception is main/production branches where you want all runs to complete:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
4. Conditional Steps and Path Filters
Not every push needs every job. If a PR only touches documentation files, running the full test suite is waste.
Path filters to skip jobs entirely:
on:
push:
paths-ignore:
- 'docs/**'
- '*.md'
- '.github/ISSUE_TEMPLATE/**'
Conditional step execution within a job:
- name: Run e2e tests
if: contains(github.event.pull_request.labels.*.name, 'run-e2e') || github.ref == 'refs/heads/main'
run: npm run test:e2e
This runs expensive E2E tests only on main branch or when a specific label is applied to the PR — useful for tests that take 10+ minutes and aren’t needed on every change.
5. Reusable Workflows
If you have the same steps repeated across multiple workflows (build, then test, then deploy), reusable workflows let you define once and call:
# .github/workflows/build.yml
on:
workflow_call:
inputs:
environment:
required: true
type: string
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
# Called from another workflow
jobs:
build:
uses: ./.github/workflows/build.yml
with:
environment: production
This avoids drift between workflows that should be doing the same thing but have diverged through copy-paste.
6. Artifact Caching Between Jobs
When one job produces output needed by another (build artefacts, compiled assets), uploading and downloading is faster than re-running the build:
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist
- run: npm run test:prod
Measuring Impact
Before optimizing, measure. GitHub Actions provides per-step timing in the UI, and the billing section shows minutes consumed per workflow. A quick audit of your slowest workflows before applying any of these techniques gives you a baseline to measure against.
Start with dependency caching — it’s the highest-return change with the lowest implementation risk. Add matrix parallelism for test suites over 5 minutes. Add concurrency controls to any workflow where you routinely cancel queued runs manually.
Most teams who apply all of these techniques see pipeline wall-clock time drop by 50–70%. For a team pushing 20 PRs a day, that’s hours of developer time returned daily without changing a line of application code.