Concurrency Groups to Cancel Stale Builds
A writer pushes a fix to a pull request, notices a typo, and pushes again thirty seconds later. Without any configuration, GitHub Actions runs two complete builds — install dependencies, build 1,300 pages, run checks, upload a preview — and the first one's result is worthless before it finishes. Multiply by a team of writers who push small commits often, and a large share of CI time goes to building commits nobody will ever look at. On the production side the opposite problem appears: two merges in quick succession start two deploys at once, and depending on which finishes last, the older commit can end up live.
Concurrency groups solve both: cancel superseded builds on pull requests, and serialize deploys to production. This guide configures them for a documentation site's workflows, explains the group naming that makes them safe, and measures the effect on CI minutes and preview latency. It is part of GitHub Actions for Automated SSG Builds.
Prerequisites
- GitHub Actions workflows for pull request builds and production deploys.
- An understanding of whether the deploy target is atomic (Cloudflare, Netlify, Vercel, GitHub Pages) or not (rsync to a server, S3 sync without versioning); see Atomic Deploys vs Incremental Uploads.
The Waste, Measured
A month of workflow runs on the documentation repository, before concurrency settings:
| Measure | Value |
|---|---|
| Pull request workflow runs | 1,620 |
| Runs superseded by a newer push to the same PR before finishing | 610 (38%) |
| Median build duration | 3 min 50 s |
| CI minutes spent on superseded runs | ~2,340 |
| Times an older commit was deployed after a newer one | 2 |
The two out-of-order deploys each left the production site one commit behind for several hours, until someone noticed a fix was missing.
Step 1: Cancel Superseded Pull Request Builds
Add a workflow-level concurrency block to the pull request workflow:
name: Preview
on:
pull_request:
concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
The group name includes the pull request number, so each pull request has its own group: a new push to PR 482 cancels the in-progress run for PR 482 and nothing else. Using just the workflow name as the group would cancel builds across all pull requests — one writer's push would cancel everyone else's preview.
For workflows that run on both pushes and pull requests, a common pattern keys on the ref and only cancels for pull requests:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
github.head_ref is the source branch on pull request events and empty otherwise, so pushes to main fall back to github.ref and are never cancelled by this rule.
Step 2: Serialize Production Deploys
Production must never run two deploys at once, and should deploy commits in order. A job-level concurrency group without cancellation queues them:
jobs:
deploy:
runs-on: ubuntu-latest
concurrency:
group: production-deploy
cancel-in-progress: false
steps: [ … ]
With cancel-in-progress: false, a second deploy waits for the first. One subtlety: GitHub keeps at most one pending run per group — if three merges happen while a deploy is running, the middle one is cancelled while pending and the newest waits. For a static site that is exactly right: the newest commit contains everything, and the intermediate deploy would have been immediately replaced. What it must never do is cancel a deploy in progress on a non-atomic target, which could leave half-uploaded files live.
On hosts with atomic deploys, cancelling an in-progress production deploy is safe but pointless — the previous version stays live until the new one is complete — so the same serialized setting works everywhere.
Step 3: Put the Group at the Right Level
Concurrency can be set on the whole workflow or on individual jobs. Putting cancellation at the workflow level for pull requests saves the most minutes, because everything stops at once. For production, putting the group on the deploy job only lets builds and tests for several commits run in parallel, with only the final deploy step serialized. On this repository, that meant a burst of merges built in parallel in about four minutes and deployed in order in about thirty seconds each.
FAQ
What does a concurrency group do in GitHub Actions?
It ensures that at most one run or job with the same group name is running at a time. When a new run starts in a group, a pending run in that group is cancelled, and with cancel-in-progress set to true, a run that is already in progress is cancelled too.
Should production deploys cancel in-progress runs?
Usually not. Cancelling a production deploy halfway can leave a partially uploaded site on hosts without atomic deploys. Use a concurrency group without cancel-in-progress for production, so deploys queue and run one at a time in order.
What group name should pull request builds use?
A name that combines the workflow and the pull request or branch, such as the workflow name plus github.head_ref or the pull request number. That cancels older builds for the same pull request without affecting other pull requests.
Do cancelled runs count toward billing?
Minutes already used before cancellation are billed; the rest are saved. On the repository measured here, cancelling superseded pull request builds saved about 38 percent of monthly CI minutes.
Related Settings That Compound
Concurrency groups work best together with two other settings. Path filters (on.pull_request.paths) skip runs entirely for changes that cannot affect the site. And the "Cancel workflow runs on new commits" behaviour of required status checks means a cancelled run shows as cancelled, not failed; branch protection treats the newest run's result as authoritative. The team documented both in the shared workflow described in Reusable Workflows for SSG Builds, so every site got the same behaviour.
Concurrency Across Workflows and Repositories
Groups are global across a repository's workflows, which can be used deliberately. If a site has one workflow that deploys on pushes to main and another that redeploys on a nightly schedule to refresh time-based content, giving both deploy jobs the same group name (production-deploy) prevents them from ever deploying at the same moment, even though they are separate workflows. The same trick coordinates a content rebuild triggered by a CMS webhook through repository_dispatch with ordinary code deploys.
Groups do not span repositories. When two repositories deploy to the same destination — a docs site and an API reference that both publish into one bucket, for example — concurrency groups cannot serialize them. In that case the safer design is for one repository to own the deploy and the other to trigger it, or for each to deploy to its own path prefix so they cannot interfere, as in the monorepo patterns in Matrix Builds for Multi-Site Monorepos.
It is also worth checking which runs show as "cancelled" in dashboards and notifications. Chat integrations that post every failed or cancelled run can become noisy once cancellation is routine; filtering notifications to failures on main kept the signal useful.
Measured Impact
One month before and after, same repository and similar activity:
| Measure | Before | After |
|---|---|---|
| Pull request CI minutes per month | 6,210 | 3,870 |
| Median time from last push to preview ready | 6 min 20 s (queued behind stale runs) | 4 min 05 s |
| Out-of-order production deploys | 2 | 0 |
| Production deploys cancelled mid-upload | — | 0 |
| Runner queue time at peak, p90 | 4 min 40 s | 1 min 10 s |
Pitfalls & Rollback
- Group names that are too broad. A group of just the workflow name cancels other people's builds.
cancel-in-progress: trueon production. Can leave partial uploads on non-atomic targets.- Expecting every pending run to wait. Only one pending run per group is kept; intermediate ones are cancelled by design.
- Required checks named per run. Make sure required status checks refer to the job name, so a cancelled older run does not block merging.
- Rollback: remove the
concurrencyblocks; runs return to independent execution immediately.
Conclusion
Two short blocks of YAML handle two real problems. Cancelling superseded pull request builds per pull request removes the largest source of wasted CI minutes and gets writers their preview sooner; serializing production deploys at the job level guarantees they happen one at a time, in order, with the newest commit winning. On this repository that cut pull request CI minutes by 38% and ended out-of-order deploys.
Related
- Parent: GitHub Actions for Automated SSG Builds — the Actions setup as a whole.
- Deploying to Multiple Environments from One Workflow — environments and their deploy jobs.
- Branch Deploy Controls on Cloudflare Pages — the same goal on a host's own builds.
- Rolling Back a Bad Static Deploy in Under a Minute — when a deploy does go wrong.
- Measuring Build Time Regressions in CI — watching CI minutes over time.
Filed underHow-tosGitHub Actions