DEV Community

Libme
Libme

Posted on

Is Paying for Bigger CI Runners Worth It? Break-Even Math Against Caching and Test Sharding

Short answer: pay for larger runners only after you have removed the minutes that should not exist (redundant runs, cold dependency installs) and only for the steps that actually scale with cores. For most small teams, cancelling superseded runs and caching come first, sharding second, and bigger machines last, because a machine with twice the cores almost never finishes a pipeline in half the time.

This post gives you a way to decide with your own numbers instead of a vendor's "up to Nx faster" claim. Pricing models vary by provider and change often, so I describe the shape of the costs rather than quoting rates. Check your provider's current price page for the per-minute multiplier before plugging numbers in.

Where do CI minutes actually go?

Before comparing options, split one representative run into phases. On GitHub Actions the per-step durations are visible in the run's log view; every other CI system has an equivalent. I group them into four buckets:

  1. Setup (checkout, toolchain, dependency install): usually serial, usually cacheable.
  2. Build/compile: partly parallel, depends on your toolchain.
  3. Tests: the most parallelizable bucket if tests are independent.
  4. Waste: runs nobody needed, such as a push-after-push where the first run is already obsolete.

What tripped me up the first time I did this exercise was the waste bucket. It does not show up inside any single run, only across runs. Listing the last few dozen runs and counting how many were cancelled-or-superseded by a newer commit on the same branch is more informative than any single profile.

Takeaway: you cannot choose between caching, sharding, and bigger runners until you know which bucket your minutes live in.

What should I fix before paying for anything?

Two changes cost nothing and remove minutes without touching speed.

First, cancel runs that a newer push has made obsolete:

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
Enter fullscreen mode Exit fullscreen mode

Keeping cancel-in-progress off for main is deliberate: you generally want every merge commit to finish its checks, while feature branches only care about the latest push.

Second, stop cold-installing dependencies. Most official setup actions for Node, Python, and others have a built-in cache option keyed on your lockfile:

- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: npm
- run: npm ci
Enter fullscreen mode Exit fullscreen mode

Caching helps only if the install is a meaningful share of the run. If it is 20 seconds out of 10 minutes, move on. Also note the limitation: caches are evicted under storage limits and are scoped by branch, so a cache restored on a feature branch comes from that branch or the default branch, not from a sibling. A cache that mysteriously misses is usually a key problem, not an outage.

Takeaway: cancelling superseded runs and caching lockfile-keyed dependencies are free, and they change what the later options are worth.

Does a bigger runner make CI proportionally faster?

No, and the reason is Amdahl's law: if a fraction p of the run can use extra cores and the rest is serial, the speedup from n times the compute is 1 / ((1 - p) + p / n). Checkout, installs, a single-threaded bundler step, and a database migration are all serial. A pipeline that is 60% parallelizable and runs on a machine with four times the cores speeds up by roughly 1.8x, not 4x. Meanwhile the per-minute price of a larger runner typically scales with its size, so you pay a multiple of the rate for a smaller multiple of the speed.

Here is a script that turns your phase measurements into a break-even check. It uses only the standard library:

def run_cost(minutes, rate_multiplier):
    """Billed cost in 'standard-runner-minutes'."""
    return minutes * rate_multiplier

def speedup(parallel_fraction, cores_multiplier):
    return 1 / ((1 - parallel_fraction) + parallel_fraction / cores_multiplier)

def compare(base_minutes, parallel_fraction, cores_multiplier, rate_multiplier):
    new_minutes = base_minutes / speedup(parallel_fraction, cores_multiplier)
    return {
        "new_minutes": round(new_minutes, 1),
        "billed_before": run_cost(base_minutes, 1),
        "billed_after": round(run_cost(new_minutes, rate_multiplier), 1),
    }

# Replace these with measurements from your own pipeline:
print(compare(base_minutes=12, parallel_fraction=0.6,
              cores_multiplier=4, rate_multiplier=4))
Enter fullscreen mode Exit fullscreen mode

With those placeholder inputs (they are illustrative, not measured), the run gets shorter but the billed cost goes up. The only thing you are buying is developer waiting time. That can still be a good trade, which is the next question.

Takeaway: a larger runner is a purchase of wall-clock time at a worse unit price, so the only valid justification is that wall-clock time is worth more than the extra billed minutes.

When is the faster pipeline worth the extra spend?

Convert waiting time into a number. Take the number of runs per day that a human actually waits on, multiply by minutes saved per run, and compare with the added billed cost. If five engineers each wait on the pipeline several times a day, shaving a few minutes per run is real focus time. If most runs are bot-triggered or nobody watches them, the saved minutes are worth close to nothing.

Option Cuts billed minutes Cuts wall-clock time Main drawback
Cancel superseded runs Yes No Needs care on main and release branches
Dependency/build caching Yes Yes Key mistakes cause silent misses or stale builds
Test sharding (matrix) No, usually slightly up Yes Setup cost repeats per shard; uneven shards
Larger runner No, usually up Yes, sub-linearly Higher unit price for serial steps
Self-hosted runner Shifts cost to your infra Depends on hardware You own patching, isolation, and scaling

Sharding is the middle path, and it is worth showing because the tooling is built in. Jest and Playwright both support splitting a suite with a --shard flag:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx jest --shard=${{ matrix.shard }}/4
Enter fullscreen mode Exit fullscreen mode

The catch: each shard repeats checkout and install, so four shards cost more billed minutes than one run, and the slowest shard sets the finish time. Shard counts beyond what your install overhead justifies give diminishing returns quickly.

If you decide the managed option is the right call for the serial-heavy build step, GitHub Actions larger runners are the version of this that needs no infrastructure work from you, with the tradeoff that you pay the higher rate for every minute including the serial ones.

Takeaway: shard the parallel part and keep the serial part on cheap machines, rather than upgrading the whole job.

What about self-hosted runners?

Self-hosted runners trade a bill for an operations job. You pay for the hardware (or cloud instances) and for the time to patch, isolate untrusted code from forks, and scale them. Billing rules for self-hosted runners on hosted CI platforms have been under revision, so verify the current policy before building a business case on "self-hosted is free." For a team under roughly a handful of engineers, the maintenance time usually exceeds the saving unless builds are very heavy or need special hardware.

Takeaway: self-hosting is a hardware-and-ops decision, not a discount.

FAQ

Are larger GitHub Actions runners worth it?
Only for jobs that are mostly parallel, such as compiling or running a big independent test suite, and only when engineers are actually waiting on the result. For serial-heavy pipelines you pay a higher per-minute rate for little speedup.

Why is my CI slower after adding more shards?
Each shard repeats checkout and dependency install, and the pipeline finishes when the slowest shard does. If shards are uneven or setup is a large share of the run, more shards add overhead faster than they remove test time.

How do I reduce CI minutes without slowing builds down?
Cancel superseded runs on feature branches, cache dependencies keyed on your lockfile, and skip jobs whose paths did not change. These remove work instead of buying speed.

Bottom line

Measure one representative run by phase, then count how many runs in the last month were obsolete. Fix cancellation and caching first because they lower the bill and the wait. Shard tests when the test phase dominates and the install is small. Buy larger runners only for steps you have shown scale with cores, and only when someone's waiting time is worth more than the extra billed minutes.

Related reading

Top comments (0)