runmypr Guides · Mergestorm affiliate Try Mergestorm

Guides · CI

How to speed up GitHub Actions CI (and which jobs to move off it)

Published Oct 8, 2026 · ~9 min

A CI pipeline with fast and slow jobs

Short answer: find the job on your critical path, take the free wins (cancel superseded runs, skip unaffected jobs, cache dependencies, shard tests), and only then change runners. When you do, split by job type: keep short, light, bursty jobs on GitHub-hosted runners and move long, CPU- and dependency-heavy jobs to faster runners with warm caches.

Slow CI is review time in disguise. Every push waits for green checks before a reviewer looks again, so a 15-minute pipeline adds 15 minutes to every round of review. See PR cycle time, explained for where that wait sits.

1. Find the jobs that set your wall-clock time

A workflow is as slow as its longest chain of dependent jobs. Speed up anything else and nobody notices. This prints the average duration of each job over your last 10 successful runs:

REPO=your-org/your-repo
for id in $(gh run list -R "$REPO" --workflow ci.yml --status success \
    --limit 10 --json databaseId --jq '.[].databaseId'); do
  gh run view "$id" -R "$REPO" --json jobs \
    --jq '.jobs[] | select(.conclusion == "success")
      | [.name, (((.completedAt | fromdate) - (.startedAt | fromdate)) / 60)] | @tsv'
done | awk -F'\t' '{ t[$1] += $2; n[$1]++ }
  END { for (j in t) printf "%5.1f min  %s\n", t[j] / n[j], j }' | sort -rn

Here is the output for vitejs/vite (Oct 8, 2026):

  5.5 min  Build&Test: node-24, windows-latest
  5.5 min  Build&Test: node-24, macos-latest
  4.4 min  Build&Test: node-20, ubuntu-latest
  4.2 min  Build&Test: node-22, ubuntu-latest
  3.7 min  Build&Test: node-26, ubuntu-latest
  3.7 min  Build&Test: node-24, ubuntu-latest
  1.7 min  Lint: node-24, ubuntu-latest
  0.2 min  Get changed files
  0.1 min  Build & Test Passed or Skipped

This shape is typical, and it tells you two different things. The Build&Test jobs are the critical path: shaving a minute off them shortens every run. The tiny jobs, such as “Get changed files”, which decides what the rest of the pipeline needs to run, take seconds. They don’t need a faster machine. They need to start instantly, however many run at once.

2. Take the free wins first

Cancel runs that a newer push replaced

Push three fixes in a row and, by default, three full runs compete for runners. Cancel the older ones:

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }}
  cancel-in-progress: true

On a branch, every push shares one group, so a new push cancels the older run. On main, each commit gets its own group (its SHA), so nothing is cancelled and every merged commit gets a full result. Setting cancel-in-progress: false for main is not enough: GitHub keeps only one pending run per group, so a third merge would still replace the second one’s queued run.

Skip work the change can’t affect

A docs change does not need the full test matrix. Use paths filters on the workflow, or a small first job that works out what changed (like Vite’s “Get changed files”) and lets later jobs skip themselves. If a skipped job is a required check, use the job-level approach: a skipped job still reports, a filtered-out workflow never starts and leaves the check pending.

Cache dependencies

Installing packages from scratch on every run is often a minute or more. The setup actions cache for you with one line:

- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: npm

On GitHub-hosted runners the cache still has to be downloaded at the start of each job and uploaded when it changes, so a big cache still costs time. Keep that in mind for step 4.

Shard the slowest test suite

If one test job dominates, split it across a matrix. Most test runners support it directly (for example vitest --shard=1/4, jest --shard=1/4, pytest-split). Four shards of three minutes beat one job of twelve, as long as each shard’s setup is short, which is why caching comes first.

3. Know what GitHub-hosted runners give you

From GitHub’s own documentation (checked Oct 8, 2026):

Sources: GitHub-hosted runners, Actions runner pricing, GitHub Actions billing.

GitHub’s real strength is burst capacity. When a merge queue builds five batches at once, or a monorepo fans out forty tiny jobs, GitHub runs them in parallel up to your plan’s concurrency limit, and you pay for a few seconds each. No self-managed fleet absorbs a spike like that as cheaply.

4. Put each job on the runner that suits it

Once the free wins are in, the remaining time is mostly the long jobs: test suites, builds, bundlers, Docker images. These are where a faster runner with a warm cache pays off. Mergestorm Surge runs GitHub Actions jobs on on-demand runners. According to Mergestorm’s Surge docs:

Here is how the two compare for a private repository:

GitHub-hosted (standard)Mergestorm Surge (standard)
CPU / memory2 CPU / 8 GB~2.6 vCPU / 4.4 GB
Price per minute$0.006$0.005
Dependency cacheDownloaded and uploaded each jobAlready on disk
Bigger size4-core from $0.012/minLarge, 2× resources, $0.010/min
Burst of many tiny jobsExcellentLimited by fleet size (public beta)
OSLinux, Windows, macOSLinux x64 only

So a sensible split looks like this:

Moving one job

Surge routes a job with a repository variable that Mergestorm sets only while a Surge runner is available. When it isn’t (Surge is off, paused or full), the same job runs on GitHub-hosted runners with no edit:

jobs:
  changes:            # seconds long, runs on every push: stay on GitHub
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
  test:               # the long job: Surge when available
    needs: changes
    runs-on: ${{ vars.CI_SURGE_RUNNER || 'ubuntu-latest' }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci && npm test

For a CPU-bound job, ask for the large size with runs-on: ${{ vars.CI_SURGE_RUNNER && 'ms-surge-large' || 'ubuntu-latest' }}.

Limits worth knowing: Surge serves private repositories only, does not support services: or container: jobs, and expects toolchains to be installed in the job (for example with actions/setup-node) rather than preinstalled. It is in public beta.

A checklist

  1. Measure job durations and find the critical path.
  2. Cancel superseded runs on branches.
  3. Skip jobs the change can’t affect.
  4. Cache dependencies; shard the slowest suite.
  5. Keep tiny, frequent, bursty jobs on GitHub-hosted runners; move long Linux jobs to faster cached runners.
  6. Measure again after a week, with the same command.

Faster CI shortens every review round. For the other half of the wait, see how long a PR review should take and how big a pull request should be.