Using GitHub Actions Concurrency Groups to Cancel Superseded CI Runs
Multiple quick pushes can trigger redundant GitHub Actions workflows. Learn how concurrency groups with cancel-in-progress reduce CI waste, the trade-offs of aggressive cancellation, and how to configure it safely.
30 Aug 2026, 09:50 UTC

You push three commits in ten minutes. By the time the first CI run finishes, the branch has already moved ahead. The second and third runs are executing against code that no longer reflects the latest state. You are paying for compute, waiting for stale feedback, and scanning a dashboard cluttered with obsolete checks.
GitHub Actions concurrency groups solve this by letting you define which runs are allowed to coexist. When a new run enters a group that already has an active member, GitHub can automatically cancel the older one. The result is tighter feedback loops and lower CI spend—provided you group runs correctly and accept the trade-offs.
Why superseded runs pile up
Every push or pull request update triggers a new workflow run. Without intervention, those runs execute independently. A developer amending a commit message or fixing a typo generates a full CI cycle that tests code the team has already discarded. On busy repositories, this means queues of redundant jobs, inflated Actions minutes, and noisy status checks that obscure the run that actually matters.
Concurrency groups do not prevent runs from starting. Instead, they enforce a rule: only one run per group may proceed at a time, and optionally, newer runs supersede older ones.
How concurrency groups work
The concurrency key is available at both the workflow level and the job level. It accepts a group string and a cancel-in-progress boolean. The group string can include dynamic expressions such as ${{ github.ref }}, which resolves to the Git reference (branch or tag) that triggered the run, or ${{ github.workflow }}, which resolves to the workflow name.
When cancel-in-progress is set to true, any run already pending or in progress in that group is cancelled as soon as a new run joins. If it is false or omitted, the new run waits until the existing one finishes.
Workflow-level concurrency has been available longer than job-level concurrency. If you use GitHub Enterprise Server, confirm that your server version supports the syntax before deploying it broadly; job-level concurrency in particular was introduced after the workflow-level key.
A practical configuration
Place the following in a workflow file under .github/workflows/. Editing workflow files requires write access to the repository.
name: CI
on:
push:
branches: [main]
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 ci
- run: npm test
In this example, the group key combines the workflow name and the Git ref. A push to main and a pull request targeting main land in separate groups because their github.ref values differ. Two rapid pushes to the same branch share a group, so the second push cancels the first.
Be careful with expressions that evaluate to empty strings. On non-pull-request events, github.head_ref is empty. If your group key relies on it without a fallback, unrelated runs may collapse into a single group. A safer pattern uses a fallback expression:
group: ${{ github.head_ref || github.ref }}
To verify the behavior, push two commits to a branch within a few seconds. Open the Actions tab. You should see the newer run in progress and the older run marked as cancelled. The cancelled run’s logs remain accessible, and any jobs that completed before the cancellation still report their original status.
Trade-offs and limitations
Cancellation is not deletion. A cancelled run stays in the run history with its logs and artifacts intact. This preserves an audit trail, but it can clutter dashboards if your repository generates many superseded runs. Retention policies can help manage the volume, but they are separate from concurrency configuration.
Self-hosted runners introduce another delay. If the runner process is busy executing a step, it may not honor the cancellation signal immediately. The job can continue for a short period after GitHub marks it cancelled, consuming runner capacity in the meantime.
The most significant risk is losing useful signal. Aggressive cancellation can terminate a long-running integration test that was about to pass. If that test would have caught a real regression, you lose the result and must rerun it after the next push. On a busy branch, this can delay the final green build rather than accelerate it.
An actionable approach
Start conservatively. Apply workflow-level concurrency with cancel-in-progress: true to fast, idempotent workflows such as linting or unit tests, where a rerun is cheap. Leave long-running release or integration workflows without automatic cancellation, or use job-level concurrency selectively for the specific jobs that are safe to interrupt.
After enabling the pattern, monitor the Actions tab for a few days. Confirm that superseded runs are being cancelled as expected, that logs remain available for debugging, and that no critical long-running jobs are being killed prematurely. Adjust the group key if you notice unrelated runs sharing a group.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.