Back to Blog

What Nobody Tells You About GitHub Actions Concurrency: Stop Wasting CI Minutes on Redundant Builds

What Nobody Tells You About GitHub Actions Concurrency: Stop Wasting CI Minutes on Redundant Builds

I’ve watched too many developers stare at a GitHub Actions tab while three identical PR builds fight for the same runner. It’s a quiet sort of chaos. You push a change, spot a typo five seconds later, push the fix, and now you’re paying for two builds when you only care about the result of the last one.

GitHub’s default behavior is to be helpful—it wants to run every single thing you tell it to. But in a fast-moving PR, that helpfulness quickly turns into a bottleneck. If your CI takes ten minutes and you push three times in half an hour, you've just queued up 30 minutes of compute time. Most of that is literal garbage.

The "Eager" Problem

By default, GitHub Actions is a "first-in, first-out" machine. If you trigger a workflow, it runs. If you trigger it again while the first is running, it starts a second instance.

This leads to a few headaches:
1. Burning Minutes: You’re using up your private repo minutes (or your company’s money) on code that is already obsolete.
2. Race Conditions: I've seen deployment pipelines get tangled because a previous "stale" build finished *after* a newer one, overwriting a fresh staging environment with old code.
3. Queue Bloat: If you have a small pool of self-hosted runners, one hyperactive developer can clog the entire pipe for the rest of the team.

The solution is the concurrency key. It's been around for a while, but it's often overlooked or implemented in a way that causes more problems than it solves.

The Magic Syntax

The concurrency configuration belongs at the top level of your workflow YAML. At its simplest, it looks like this:

name: CI
on: [push, pull_request]

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

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test

Let's break down why this specific setup works.

The group name acts like a unique ID. GitHub looks at all running workflows; if it finds another one with the same group ID, it considers them part of the same "family."

The cancel-in-progress: true is the actual heavy lifter. It tells GitHub: "If you see a new workflow starting in this group, kill any older ones immediately."

Don't Hardcode Your Group Names

A common mistake is naming the group something static, like group: ci-build. If you do that, only one CI build can run in the entire repository at once.

If Alice pushes to feature-a and Bob pushes to feature-b, Bob's push will cancel Alice's build. Alice will be very annoyed.

You need to make the group name dynamic. Using ${{ github.ref }} ensures the concurrency limit only applies to the specific branch or PR you’re working on.

* Alice pushes to `feature-a`: Group is CI-refs/heads/feature-a.
* Bob pushes to `feature-b`: Group is CI-refs/heads/feature-b.
* They don't touch each other.

But if Alice pushes to feature-a twice in two minutes? The first one dies so the second one can live. That’s exactly what we want.

The One Place You Shouldn't Use This

There is a massive "Gotcha" here: Deployments.

If you are running a workflow that deploys to production or manages infrastructure (like Terraform or Pulumi), cancel-in-progress: true is your enemy.

Imagine your workflow is halfway through a database migration or an AWS resource update. If you push a small CSS fix and GitHub suddenly "kills" the migration mid-stream to start the new build, you are going to have a very bad Friday. You'll likely end up with a corrupted state or a partial deployment.

For deployment workflows, I usually omit cancel-in-progress or set it to false.

concurrency:
  group: production-deploy
  cancel-in-progress: false 

When cancel-in-progress is false, GitHub won't kill the running job. Instead, it will put the new job in a "Pending" state. It waits for the first one to finish before starting the next. This ensures your deployments happen in a safe, serial order without overlapping.

Refined Logic for PRs

If you want to be really surgical, you can use the PR number in your concurrency key. This is helpful if you’re using the same branch for multiple PRs (which is weird, but it happens) or if you want to ensure that a PR build doesn't conflict with a branch build.

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

The || (OR) logic here is a nice fallback. If the event is a Pull Request, it uses the PR number. If it’s just a standard push to a branch, it falls back to the branch reference.

The Bottom Line

Stop letting obsolete builds eat your time. If you’re running tests, linting, or type-checking, there is almost no reason to let an old build finish once a new commit has been pushed.

Add a concurrency block to your CI YAML today. It takes thirty seconds to type, saves hours of collective waiting time over a month, and keeps your Actions tab from looking like a graveyard of redundant checks.