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. If projects this polished leave minutes on the table, the rest of us definitely do.
Your suite runs twice on every PR
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:Old runs don't cancel when you push again
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: astro has a concurrency group on one workflow but left off cancel-in-progress, which our scan estimates leaves roughly 1,850 minutes a month on the table. Add a group keyed on the branch, with cancel-in-progress, so a new push supersedes the old run:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: trueEvery run reinstalls dependencies from scratch
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'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 README typo runs your whole test suite
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
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: 15How 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.
Scan your repo free