CI runs too often
Canceled runs are wasting your CI minutes
By Keith Mazanec, Founder, CostOps ยท Updated January 30, 2026
A developer pushes 6 commits to a PR branch over 20 minutes. GitHub Actions starts 6 full CI runs. Each new push makes the previous run obsolete, so 5 of the 6 get canceled, whether manually or by a concurrency group. But GitHub still bills for every minute those canceled runs were active before cancellation. If each run gets 4 minutes in before it’s killed, that’s 20 minutes of paid compute that produced zero useful results. This is fixable. Concurrency groups minimize the window, and reducing full CI on branch pushes by debouncing heavy workflows to PR-ready events eliminates the problem entirely.
Symptoms
How to tell if canceled runs are costing you money
Open your repository’s Actions tab. If you see stacks of grey “cancelled” badges clustered together, you have this problem. Here are the specific patterns to look for:
-
High canceled-minutes share. A meaningful percentage of your total billable minutes comes from runs that never completed. GitHub rounds each job’s execution time up to the nearest whole minute, so even a job that runs for 10 seconds before cancellation costs a full minute. Across many canceled runs, this rounding compounds fast.
-
Clusters of canceled runs after rapid pushes. Multiple runs are canceled within minutes of each other, each superseded by the next push. The Actions tab shows a pattern of grey badges stacked between green or red results, and every one of them represents paid minutes that delivered nothing.
-
Canceled runs show as “failed” in the UI. GitHub marks concurrency-canceled workflows with a red × icon, identical to actual failures. This erodes team trust in CI status because developers start ignoring red badges, thinking “it was probably just a cancellation.” Real failures hide in the noise.
-
Multi-job workflows amplify the cost. A workflow with 8 jobs that gets canceled after 3 minutes doesn’t cost 3 minutes. It costs 8 minutes minimum (one rounded minute per job). If those jobs ran for 3 minutes each before cancellation, that’s 24 billed minutes from a single canceled run.
Metrics
What canceled runs actually cost
Consider a team where each PR averages 6 pushes before merge. Without concurrency groups, each push starts a full run. With concurrency groups but no debouncing, each push still starts a run, only for the previous one to get canceled quickly. The canceled runs still accumulate minutes. Here’s a typical scenario with 10 PRs merged per day, each with a 15-minute pipeline and 4 jobs:
Before - no concurrency groups
At $0.006/min (Linux 2-core) · 22 working days
After - concurrency groups + debouncing
Save $53/mo · $632/year · per workflow
That’s one workflow on Linux. On macOS runners at $0.062/min, the same 8,800 wasted minutes cost $546/mo. On Windows at $0.010/min, it’s $88/mo. The waste scales linearly with team size and push frequency, so a team that pushes more often wastes more on canceled runs.
Fix 1
Use concurrency groups to auto-cancel superseded runs
The concurrency group is the first line of defense. When a new run enters a concurrency group, GitHub immediately cancels any in-progress run in the same group. This minimizes the window between when a run starts and when it gets canceled. Instead of running for 8 minutes before a developer manually cancels it, the run gets killed within seconds of the next push.
The group key creates a unique identifier. The standard pattern uses workflow name + branch reference, so runs on different branches don’t cancel each other. Add cancel-in-progress: true to enable automatic cancellation.
name: CI on: push: branches: [main] pull_request: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true
With this in place, 6 rapid pushes to a PR branch result in 5 near-instant cancellations and 1 completed run. The canceled runs still accumulate a small number of minutes (jobs that started before the cancellation signal arrived), but the waste window shrinks from minutes to seconds.
Each canceled run still bills for its active seconds, rounded up per job. Concurrency groups minimize that window.
One caveat: don’t cancel runs on main. Pushes to main represent merged code that should always be validated. Use a conditional expression for cancel-in-progress:
concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
Using github.event.pull_request.number in the group key is better than github.ref alone for PR workflows. The PR number is stable across force-pushes, while github.ref changes if the branch is renamed. The || github.ref fallback handles non-PR events (pushes to main) where there’s no pull request number.
Fix 2
Debounce heavy workflows to PR-ready events
Concurrency groups reduce the damage, but they don’t eliminate it. Every push still starts a run that just gets canceled faster. The root fix is to stop triggering heavy workflows on every push by scoping your triggers. Instead, run the full suite only when it matters: when a PR is opened, marked ready for review, or when a reviewer requests changes.
The pull_request trigger accepts an types filter. By default it fires on opened, synchronize (every push), and reopened. Removing synchronize and adding ready_for_review means the full pipeline runs only at meaningful checkpoints instead of on every commit.
on: pull_request: # default types: # opened, synchronize, reopened # Every push fires synchronize # 6 pushes = 6 runs
on: pull_request: types: - opened - ready_for_review - reopened # No synchronize = no push triggers # 6 pushes = 0 runs until ready
This approach pairs well with a lightweight preflight workflow that runs lint and unit tests on every push (using the synchronize type). The developer gets fast feedback on syntax and basic correctness, while the expensive test suite only runs when the PR is ready for review.
name: CI Full # Only runs at PR milestones, not on every push on: pull_request: types: [opened, ready_for_review, reopened] push: branches: [main] merge_group: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test e2e: needs: [test] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npx playwright test
One caveat: if your full CI workflow is a required status check, removing synchronize means the check won’t re-run on new pushes. The PR will show the result from the last time the workflow triggered. To force a re-run, the developer can convert to draft and back to ready, or you can add a manual workflow_dispatch trigger. Alternatively, keep synchronize and rely solely on concurrency groups (Fix 1) to minimize waste.
Fix 3
Add job-level timeouts to limit cancellation damage
When a concurrency group cancels a run, GitHub sends SIGINT to the running process and waits up to 7.5 seconds for graceful shutdown. If the process doesn’t exit, it sends SIGTERM, then force-kills the process tree. But if a step ignores these signals or hangs during cleanup, the job can keep running and billing for up to 5 minutes before the server forcibly terminates it.
The default job timeout in GitHub Actions is 360 minutes (6 hours). A stuck canceled job can burn 6 hours of minutes before GitHub kills it. Setting CI timeouts with explicit timeout-minutes on each job caps the damage.
jobs: lint: runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - run: npm run lint test: runs-on: ubuntu-latest timeout-minutes: 20 steps: - uses: actions/checkout@v4 - run: npm test e2e: runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@v4 - run: npx playwright test
Set timeout-minutes to roughly 2× the expected job duration. A lint job that takes 2 minutes gets a 5-minute timeout. A test suite that takes 10 minutes gets 20. This protects against hung processes without prematurely killing legitimate long-running jobs. Combined with concurrency groups, timeouts ensure a canceled run can never spiral into hours of wasted minutes.
Reference
How GitHub rounds canceled run billing
GitHub rounds each job’s execution time up to the nearest whole minute. This rounding applies to every job individually, including jobs in canceled runs. The impact compounds with job count:
| Scenario | Actual time | Billed time | Overhead |
|---|---|---|---|
| 1 job, canceled at 10s | 10s | 1 min | 6× |
| 4 jobs, canceled at 30s each | 2 min | 4 min | 2× |
| 8 jobs, canceled at 90s each | 12 min | 16 min | 1.3× |
| 8 jobs, canceled at 10s each | 1.3 min | 8 min | 6× |
The worst case is many short-lived jobs in a canceled run, because each one rounds up to a full minute. This is why workflows with many small parallel jobs (lint, typecheck, unit tests, integration tests as separate jobs) pay a disproportionate “cancellation tax.” Concurrency groups that cancel runs before jobs even start are the most effective defense: a job that never starts costs zero minutes.
Related guides
Stop CI Running on Every Push
Use concurrency groups, scoped triggers, and path filters to cut redundant CI runs.
Fix Duplicate CI Runs from Misconfigured Triggers
Eliminate double runs caused by unscoped push and pull_request triggers.
Reduce Full CI on Branch Pushes
Scope triggers to PRs and main to cut non-PR CI minutes 60-80%.
Set Timeout-Minutes to Stop Paying for Hung Jobs
Cap wasted CI spend with explicit timeout-minutes on every job and step.