The Problem
A CI pipeline that takes 12 minutes feels fine on day one. Six months later it is the bottleneck in every pull request, developers context-switch while they wait, and the Actions bill has quietly tripled. Most of that time is waste: re-downloading the same dependencies, running jobs serially that could run in parallel, and launching duplicate runs for commits nobody will ever merge. Optimization is less about clever tricks and more about not repeating work.
Below are the levers, roughly in order of return on effort. Caching and concurrency groups usually give the biggest wins for the least work.
Cache Dependencies
The single biggest time sink in most pipelines is downloading dependencies on every run. Cache them so a warm run skips the install entirely.
Many setup-* actions have caching built in — turn it on with one line:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm" # built-in: caches ~/.npm keyed on package-lock.json
- run: npm ci
For anything without built-in support, use actions/cache directly with a key tied to your lockfile so the cache busts only when dependencies change:
steps:
- uses: actions/cache@v4
with:
path: ~/.gradle/caches
key: gradle-${{ runner.os }}-${{ hashFiles('*/.gradle*') }}
restore-keys: |
gradle-${{ runner.os }}-
The restore-keys fallback lets a partial cache load even when the exact key misses, so you rarely start from zero.
Run Jobs in Parallel
Independent work should not run serially. A matrix splits a long test suite into shards that run at the same time.
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test -- --shard=${{ matrix.shard }}/4
Four shards finish in roughly a quarter of the wall-clock time. You pay for the same total compute minutes, but developers get feedback far sooner — and faster PRs are usually worth more than the marginal cost.
Cancel Superseded Runs with Concurrency
When a developer pushes three commits in a row, the first two runs are wasted — nobody cares about results for code that is already replaced. A concurrency group cancels in-flight runs for the same branch.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
This is a two-line change that can cut a busy repo's minute usage dramatically. Keep cancel-in-progress: false for deploy workflows, where you do not want to interrupt a release mid-flight.
Skip Work with Path Filters
If a workflow only touches the frontend, do not run it when someone edits the docs. Path filters stop irrelevant runs before they start.
on:
pull_request:
paths:
- "src/**"
- "package.json"
- ".github/workflows/ci.yml"
A PR that changes only README.md now skips the whole pipeline. Use paths-ignore for the inverse when the exclusion list is shorter.
Cut Duplication with Reusable Workflows and Composite Actions
Copy-pasted YAML across five repos drifts and rots. Two features fix this:
- Reusable workflows — a whole workflow another workflow can call with inputs.
- Composite actions — a bundle of steps you reference like any other action.
# .github/workflows/reusable-build.yml
on:
workflow_call:
inputs:
node-version:
type: string
default: "20"
jobs:
build:
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 run build
# calling workflow
jobs:
call-build:
uses: ./.github/workflows/reusable-build.yml
with:
node-version: "20"
Now the build logic lives in one place. Fix a bug once and every caller inherits it.
Right-Size Runners and Clean Up Artifacts
- Match the runner to the workload. A tiny lint job does not need a 16-core machine; a heavy build might finish cheaper on a larger runner that runs for less time. Measure before upgrading.
- Set artifact retention. Default retention keeps artifacts for 90 days. Drop it to what you actually need.
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 7 # don't pay to store builds for 90 days
- Trim log noise. Verbose debug logging on every step makes failures harder to read and inflates storage. Enable it conditionally when you are actually debugging.
Common Mistakes to Avoid
- Caching with a static key. A key that never changes serves stale dependencies forever. Always hash the lockfile into the key.
- Caching the wrong directory. Cache the package manager's download store (
~/.npm,~/.gradle/caches), notnode_modulesor build output, which change constantly. - Enabling
cancel-in-progresson deploys. Cancelling a release mid-flight can leave an environment half-updated. Scope concurrency carefully. - Parallelizing dependent jobs. A matrix only helps for independent work. Jobs linked by
needs:still run in sequence by design. - Ignoring artifact retention. Ninety-day retention on every build quietly becomes a real storage bill. Set
retention-daysdeliberately.
FAQ
What gives the biggest speed-up for the least effort?
Dependency caching and concurrency groups. Caching skips the single longest step on most runs, and a two-line concurrency block stops wasted work on rapid pushes — both are low-risk changes you can ship in one PR.
Does running a matrix in parallel cost more?
It costs the same total compute minutes but far less wall-clock time, since the shards run concurrently. You are trading a flat minute budget for much faster developer feedback, which is almost always the better deal.
What is the difference between a reusable workflow and a composite action?
A reusable workflow is an entire workflow (with its own jobs) that another workflow calls via workflow_call; a composite action bundles a set of steps you drop into an existing job with uses:. Use reusable workflows to share whole pipelines and composite actions to share a repeated sequence of steps.
How do I stop a workflow from running on unrelated changes?
Add paths: or paths-ignore: filters to the trigger. A workflow scoped to src/** will skip entirely when a PR only edits documentation, saving both time and billed minutes.