DEV Community

A.F.
A.F.

Posted on • Originally published at gitspider.com

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 would 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:
Enter fullscreen mode Exit fullscreen mode

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: true
Enter fullscreen mode Exit fullscreen mode

Every 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'
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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: 15
Enter fullscreen mode Exit fullscreen mode

How to find which ones you have

Curious how the big projects do it? See real 30-day Actions scorecards for popular repos, then check your own. I built a free scanner that flags exactly which of these patterns apply to any public repo, no install: gitspider.com/scan. The full writeup lives here.

Top comments (0)