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:

MeasureValue
Pull request workflow runs1,620
Runs superseded by a newer push to the same PR before finishing610 (38%)
Median build duration3 min 50 s
CI minutes spent on superseded runs~2,340
Times an older commit was deployed after a newer one2

The two out-of-order deploys each left the production site one commit behind for several hours, until someone noticed a fix was missing.

Pull request workflow runs that were superseded before finishing Vertical bars. Of 1,620 pull request workflow runs in a month, 610 were superseded by a newer push to the same pull request before they finished, and 1,010 were the latest for their pull request. The superseded runs used about 2,340 CI minutes. Pull request runs in one month 1,010 Latest run for its PR 610 Superseded before finishing
From the Actions API, counting runs whose head SHA was no longer the pull request head when they completed.

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.

Timeline of runs for three quick pushes with cancel-in-progress A timeline for one pull request. At 0 seconds push A starts run A. At 40 seconds push B arrives, run A is cancelled and run B starts. At 70 seconds push C arrives, run B is cancelled and run C starts. Run C finishes at 300 seconds and its preview is the one reviewers see. Without concurrency, all three runs would have finished, using three times the minutes. Three pushes to one pull request Push A Push B Push C Run C done 0 s run A starts 40 s A cancelled · B starts 70 s B cancelled · C starts 300 s preview updated
Cancelled runs are billed only for the seconds they ran.

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.

Concurrency settings for pull request previews and production deploys Two panels. Pull request previews use a workflow-level group named per pull request with cancel-in-progress true, so a new push cancels the old run for the same pull request only. Production uses a job-level group on the deploy job with cancel-in-progress false, so builds run in parallel but deploys run one at a time and the newest pending deploy wins. Two groups, two behaviours Previews: cancel group: preview-PR number cancel-in-progress: true workflow level other PRs unaffected saves 38% of minutes Production: serialize group: production-deploy cancel-in-progress: false deploy job only newest pending wins no out-of-order deploys
Different goals, different settings: speed and savings for previews, order and safety for production.

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.

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:

MeasureBeforeAfter
Pull request CI minutes per month6,2103,870
Median time from last push to preview ready6 min 20 s (queued behind stale runs)4 min 05 s
Out-of-order production deploys20
Production deploys cancelled mid-upload—0
Runner queue time at peak, p904 min 40 s1 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: true on 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 concurrency blocks; 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.

Filed underHow-tosGitHub Actions