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):
- The standard Linux runner for private repositories has 2 CPUs and 8 GB of memory, and costs $0.006 a minute beyond your plan’s included minutes (2,000 a month on GitHub Free, 3,000 on Pro and Team). Public repositories get 4 CPUs and 16 GB, and standard runners are free there.
- Larger Linux runners (4 cores and up) cost from $0.012 a minute.
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:
-
A standard runner gets about 2.6 vCPU and 4.4 GB; a large runner
(
ms-surge-large) gets twice that. - A standard minute costs $0.005 (minute packs: 1,000 for $5); a large minute counts as two. Paid Mergestorm plans include Surge minutes.
-
Dependency caches (npm, Yarn, pip and
~/.cache) stay on the runner’s disk per repository, so there is nothing to download or upload. Cache time, idle time and starting servers are not billed. - Each job runs in its own fresh gVisor sandbox, and runners scale to zero when CI is quiet.
Here is how the two compare for a private repository:
| GitHub-hosted (standard) | Mergestorm Surge (standard) | |
|---|---|---|
| CPU / memory | 2 CPU / 8 GB | ~2.6 vCPU / 4.4 GB |
| Price per minute | $0.006 | $0.005 |
| Dependency cache | Downloaded and uploaded each job | Already on disk |
| Bigger size | 4-core from $0.012/min | Large, 2× resources, $0.010/min |
| Burst of many tiny jobs | Excellent | Limited by fleet size (public beta) |
| OS | Linux, Windows, macOS | Linux x64 only |
So a sensible split looks like this:
- Keep on GitHub-hosted: jobs that run on every event and finish in seconds (change detection, labelers, the “all checks passed” gate), anything that fans out into many parallel jobs at once, merge-queue bursts, and every Windows or macOS job.
- Move to Surge: long Linux jobs in private repositories with heavy dependencies or CPU work, shaped like Vite’s 4-minute Linux Build&Test jobs above. That’s where a lower per-minute price, a cache that is already on disk, and the option of a large runner add up. (Vite itself is public, so its jobs get free 4-CPU GitHub runners and should stay there; Surge serves private repositories only.)
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
- Measure job durations and find the critical path.
- Cancel superseded runs on branches.
- Skip jobs the change can’t affect.
- Cache dependencies; shard the slowest suite.
- Keep tiny, frequent, bursty jobs on GitHub-hosted runners; move long Linux jobs to faster cached runners.
- 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.