Skip to main content
CI/CD·7 min read

GitHub Actions Optimization — Faster Pipelines, Lower Bills

Practical techniques to speed up and cut the cost of GitHub Actions: dependency caching, matrix parallelism, concurrency groups, path filters, reusable workflows, composite actions, right-sized runners, and artifact hygiene.

DT

DevOps Engineer & Technical Writer

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.

SLOW SERIAL PIPELINE vs OPTIMIZED PIPELINE (CACHE HIT + PARALLEL JOBS) BEFORE — serial, cold cache install deps 4m 00s build 3m 00s test (all) 4m 00s deploy 1m 00s = 12m AFTER — cache hit + parallel test matrix restore cache 0m 20s build 2m 00s test shard 1 · 1m 30s test shard 2 · 1m 30s parallel deploy 1m 00s = 4m 50s Cache restore skips the 4-minute install. Sharding the test suite runs halves in parallel. ~60% faster, same hardware

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), not node_modules or build output, which change constantly.
  • Enabling cancel-in-progress on 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-days deliberately.

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.