← All guides

Why your GitHub Actions CI is slow (and how to speed it up)

Two days ago GitHub emailed me to say one of my workflows had failed. The next day it emailed me again. I saw it, told myself I'd fix it tomorrow, and promptly forgot. It was my nightly database backup, quietly broken the whole time, and I only caught it because a failure-rate number nudged up.

A failed run at least gets you an email. A slow run gets you nothing. GitHub never pings you when CI quietly takes twice as long, runs the whole suite twice per PR, or rebuilds dependencies from scratch every time. That waste compounds where no one looks. Here are the usual culprits, each with the exact fix.

When I scanned 35 popular open-source repos, not one had a fully clean config. 32 of 35 had no concurrency control, 33 of 35 had no job timeouts, and 22 of 35 ran the full suite twice on every PR. That set has since grown into a public showcase of ~90 repos, browsable by benchmark and language, and the ratios have held. If projects this polished leave minutes on the table, the rest of us definitely do.

Your suite runs twice on every PR (push + pull_request)

Trigger a workflow on both push and pull_request and, for a branch in the same repo, opening a PR fires both. You just paid for two identical runs. This one is pure waste and it can roughly halve your PR-related minutes. Trigger on pull_request, and keep push for your default branch:

on:
  push:
    branches: [main]
  pull_request:

Careful with the branch scope: if push is left unscoped (or scoped to feature branches), Renovate and dependabot branches with open PRs double-run their whole matrix. See popular repos with this exact gap.

Old runs don't cancel when you push again (no concurrency group)

Push a fix 30 seconds after the first push and, with no concurrency group, both runs go to completion. The first is dead weight, and it is holding a slot in your queue while it finishes. This hides even when you do have a group: a concurrency block without cancel-in-progress queues the new run behind the old one instead of cancelling it, so the superseded run still finishes. Add a group keyed on the branch, with cancel-in-progress, so a new push supersedes the old run (more in the concurrency guide):

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Every run reinstalls dependencies from scratch (no cache)

No cache means every run re-downloads and rebuilds your dependencies. On a typical Node or Python project that is roughly 30 to 90 seconds per run, every run, forever. The setup-* actions cache for free, you just have to ask:

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'

The exact line differs by stack. The caching guide covers each, and there are focused ones for pip, npm/pnpm/yarn/bun, cargo, Go modules, and Gradle/Maven.

Your matrix is bigger than it needs to be

Every OS times every version multiplies your minutes. Most regressions show up on a single combination, so run a slim matrix on PRs and save the full grid for your default branch or a nightly run:

strategy:
  matrix:
    os: [ubuntu-latest]   # add macos/windows only on main or nightly
    node: [20]

A wide matrix quietly multiplies into dozens of jobs per push. The matrix fan-out guide shows how to count yours. And every macOS leg bills ~10x a Linux one, so a stray macOS axis is the biggest line on many bills.

A README typo runs your whole test suite (no paths filter)

Without a paths filter, any change triggers full CI, docs-only commits included. Scope the trigger to the files that actually affect the build:

on:
  pull_request:
    paths:
      - 'src/**'
      - 'package.json'

A hung job can run for six hours (no timeout-minutes)

With no timeout-minutes, a stuck step runs until GitHub's 6-hour ceiling. One wedged run can quietly eat a day of minutes. Cap every job:

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 15

No timeout means the GitHub 6-hour default, and it bites worst on scheduled workflows nobody watches. The timeout guide has the full picture.

Keep it fixed: gate on config waste

The frustrating part of all of this is that it grows back: a workflow gets copied without a timeout, a cache line disappears in a refactor, and nothing fails, so nobody notices. The same answer you use for test coverage works here: a ratchet that fails the build when the count gets worse. A one-line scheduled check does it (full recipe in the CI config gate guide):

- uses: GitSpider-hq/gitspider-gate-action@v1
  with:
    max-findings: 3   # set to your current count, ratchet down

Common questions

Why is my GitHub Actions CI so slow? Almost always config, not compute: the suite runs twice per PR (push + pull_request), there's no dependency cache so every run reinstalls from scratch, no concurrency group so superseded runs finish anyway, or a matrix wider than you need. None of those want a faster runner. They want a few lines of YAML.

Does a bigger runner speed up GitHub Actions? Rarely the first thing to reach for. Larger runners bill more per minute and only help genuinely CPU-bound jobs; caching dependencies and cancelling duplicate runs are free and usually take more off the wall-clock than extra cores do.

How much faster can CI realistically get? On a repo with none of these fixes in place, caching plus a concurrency group alone commonly cut 30-50% off wall-clock time, before you touch the matrix or add path filters.

How do I find what's actually slowing my CI down? The Actions tab shows per-job duration, but the waste is config-shaped and spread across every workflow file, which is exactly why it never gets fixed. A scan flags which of these patterns apply, with the fix for each.

How to find which ones you have

Curious how the big projects do it? The showcase has real 30-day Actions scorecards for popular repos. Then point GitSpider at your own.

These hide across however many workflow files you have, which is exactly why nobody sits down and fixes them. Point GitSpider at your repo and it flags which patterns apply, with the fix for each.

No signup. Public repos only. ~30 seconds.