Guides / Canceled runs wasting minutes

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

Runs/day (all pushes) 60
Canceled runs/day 50
Avg minutes before cancel 8
Monthly wasted minutes 8,800
Monthly waste $53/mo

At $0.006/min (Linux 2-core) · 22 working days

After - concurrency groups + debouncing

Runs/day (completed) 10
Canceled runs/day 0
Wasted cancel minutes 0
Monthly wasted minutes 0
Monthly waste $0/mo

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.

.github/workflows/ci.yml
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.

Push 1
cancelled
Push 2
cancelled
Push 3
cancelled
Push 4
cancelled
Push 5
cancelled
Push 6
completed

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.

Full CI on every push
on:
  pull_request:
    # default types:
    #   opened, synchronize, reopened
    # Every push fires synchronize
    # 6 pushes = 6 runs
Full CI only at checkpoints
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.

.github/workflows/ci-full.yml
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.

.github/workflows/ci.yml
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
4 jobs, canceled at 30s each 2 min 4 min
8 jobs, canceled at 90s each 12 min 16 min 1.3×
8 jobs, canceled at 10s each 1.3 min 8 min

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

Guides / Canceled runs wasting minutes

See how much canceled runs cost you

CostOps tracks canceled-minutes share, cancellation patterns, and per-workflow waste automatically. Find the money you're losing before you change the YAML.

Free for 1 repo. No credit card. No code access.

Built by engineers who've managed CI spend at scale.