self-host →

Concurrency and cancel-in-progress in GitHub Actions

Stop wasting CI minutes on redundant runs. Learn the GitHub Actions concurrency keyword: cancel superseded PR runs, serialize deploys safely, and avoid the classic group-key gotchas.

You push a commit to your pull request, spot a typo, and push again ten seconds later. GitHub happily starts a brand new workflow run for the second push — while the first one is still churning through your test suite. Do that three or four times while iterating on a PR and you’ve got a stack of runs grinding away on code nobody cares about anymore, each one burning real CI minutes.

The concurrency keyword fixes this. It lets you group runs together and tell GitHub what to do when a new one shows up: cancel the stale one, or queue behind it. Used well, it cuts wasted compute on PRs and protects your production deploys from getting killed mid-flight. Let’s dig in.

The problem: redundant runs pile up

By default, every triggering event starts its own independent workflow run, and GitHub runs them in parallel. That’s usually what you want — but not for the rapid back-and-forth of an active pull request. Each force-push, each fixup commit, each rebase fires another pull_request event, and the previous run keeps going even though its results are already obsolete.

If your CI takes 15 minutes and you push 5 times in quick succession, you’ve potentially paid for 75 minutes of compute to learn about exactly one commit. The concurrency keyword lets you say “only one run per branch matters at a time — cancel the rest.”

Workflow-level concurrency

Add a concurrency block at the top level of your workflow. It has two parts: a group (a string that identifies which runs belong together) and the optional cancel-in-progress flag.

name: CI
on:
pull_request:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: echo "Running tests..."

Any two runs that resolve to the same group string are treated as one concurrency group, and only one run per group executes at a time. With cancel-in-progress: true, the moment a new run enters the group, the in-progress run for that group is cancelled and the new one takes over.

Understanding the group key

The group is just a string, so the magic is entirely in how you build it. A common, sensible default is:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
  • github.workflow keeps each workflow file in its own bucket, so your “CI” runs don’t accidentally cancel your “Lint” runs.
  • github.ref scopes the group to a single branch or PR ref (for example refs/pull/42/merge for a PR, or refs/heads/main for a push to main).

Together they mean: “at most one CI run per branch.” Push twice to the same PR and the second push cancels the first. Push to a different branch and nothing gets cancelled, because the group string is different.

Why the run_id fallback matters

You’ll often see a more defensive variant of the group key:

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true

Here’s the subtle part. github.head_ref is only set for pull_request events — it’s the source branch of the PR. For other events (a direct push to main, a workflow_dispatch, a tag push), head_ref is empty.

If you grouped purely on something that’s empty for pushes, every push to main would land in the same group and start cancelling each other. That’s almost never what you want: each merge to main is a distinct, meaningful event, and you don’t want commit B’s run to murder commit A’s run just because they share an empty group key.

The || github.run_id fallback solves this. Because github.run_id is unique to every single run, any event that isn’t a PR gets its own private group of one — so it can never be cancelled by a sibling. PRs still group by branch (and cancel redundant runs), while pushes and dispatches run to completion. Best of both worlds.

The classic “cancel superseded PR runs” pattern

Putting it together, here’s the pattern you can drop into almost any CI workflow:

name: CI
on:
pull_request:
push:
branches: [main]
# Cancel in-progress runs for the same PR/branch, but let
# pushes to main (and other non-PR events) run to completion.
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm test

Now a developer can push as many fixup commits to their PR as they like — only the latest run survives, and everything older is cancelled the instant it’s superseded. The CI minutes you save here are pure waste eliminated.

RunsOn tip: Cancelling redundant runs is the cheapest CI optimization there is — you simply stop paying for compute nobody will read. To go further and make the runs that do survive both cheaper and faster, run them on RunsOn self-hosted runners inside your own AWS account, at a fraction of GitHub-hosted prices.

Job-level concurrency

Concurrency isn’t only a workflow-level setting — you can apply it to an individual job too. This is handy when most of your workflow can run freely in parallel, but one specific job needs to be serialized.

jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo "Tests run freely, no concurrency limits here"
deploy-preview:
needs: test
runs-on: ubuntu-latest
# Only one preview deploy per branch at a time.
concurrency:
group: preview-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v6
- run: ./deploy-preview.sh

The same group / cancel-in-progress semantics apply, just scoped to that one job. Job-level groups live in the same namespace as workflow-level ones, so a workflow-level group and a job-level group with the same string will contend with each other — pick distinct names to avoid surprises.

Serializing deploys: queue instead of cancel

Here’s where cancel-in-progress flips. For deploys and releases, cancelling an in-progress run is the last thing you want — killing a deploy halfway through can leave production in a broken, half-migrated state. Instead, you want new deploys to wait their turn.

That’s exactly what the default (cancel-in-progress: false) gives you:

name: Deploy
on:
push:
branches: [main]
concurrency:
group: production-deploy
cancel-in-progress: false # this is the default; shown for clarity
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: ./deploy-to-production.sh

Note the group key here is a fixed string (production-deploy), not branch-scoped — because there is exactly one production, and every deploy to it must be serialized against every other.

How “at most one running + one pending” works

This is the part that trips people up, so let’s be precise. When cancel-in-progress: false and a deploy is already running:

  • The running deploy is left alone — it always finishes. Good; production is safe.
  • A newly-triggered run does not start immediately. It enters the group as pending and waits.
  • Crucially, GitHub allows at most one running and one pending run per group. So if a deploy is running, one run waits in the wings. If yet another deploy is triggered while one is already pending, the previously pending run is cancelled and the newest one takes the pending slot.

In other words, cancel-in-progress: false does not mean “queue everything.” It means “never cancel the running one.” Pending runs are still superseded — only the newest queued run survives to deploy next. This is actually the behavior you want for deploys: when three merges land while a deploy is running, you don’t need to deploy each intermediate state — you just need the running deploy to finish, then deploy the latest. The middle one is safely dropped.

So the lifecycle for a busy main branch looks like:

  1. Merge A → deploy A starts running.
  2. Merge B (while A runs) → deploy B becomes pending.
  3. Merge C (while A still runs, B pending) → deploy B is cancelled, deploy C becomes pending.
  4. Deploy A finishes → deploy C starts. B’s state was never deployed, which is fine — C supersedes it.

Gotchas

A few things that bite people in practice:

  • Never use cancel-in-progress: true on deploy or release jobs. A cancelled deploy can corrupt state, leave a half-published artifact, or abort a database migration partway through. Deploys should queue (default false), never cancel.
  • Concurrency group names are case-insensitive. Prod and prod are treated as the same group. Don’t rely on capitalization to separate groups — they’ll collide.
  • An empty group expression disables concurrency. If your group expression evaluates to an empty string, GitHub treats the run as having no concurrency group at all, so it won’t be limited. This is a common silent failure: if github.head_ref is empty (a non-PR event) and you didn’t add the || github.run_id fallback, you may end up with no concurrency control where you expected it — always provide a non-empty fallback.
  • Cancellation isn’t instant or graceful by default. When a run is cancelled, steps get a cancellation signal, but in-flight commands may still be terminated abruptly. Don’t put irreversible side effects (publishing, tagging, deploying) in a job that’s eligible for cancellation.

Conclusion

The concurrency keyword is two patterns wearing one hat. For pull requests, group by branch and set cancel-in-progress: true so rapid pushes throw away their own stale runs — the simplest CI cost win you’ll find. For deploys and releases, use a fixed group and the default cancel-in-progress: false so production work queues safely and never gets killed mid-flight, with GitHub keeping at most one running and one pending run per group. Get the group key right — including the github.head_ref || github.run_id fallback so non-PR runs don’t cancel each other — and you’ll stop wasting minutes without ever risking a broken deploy.