How to Speed Up a Slow CI Pipeline
A prioritized toolkit for cutting CI time: measure the critical path first, then cache, parallelize, run only what changed, and shrink the work itself.
A 22-minute pipeline on every push is the kind of thing a team stops noticing. People context-switch, PRs pile up, and the merge queue gets sluggish. Then someone finally profiles it and finds that half of that time is a dependency install that could be cached, and a chunk of the rest is a test job running on a runner that sat idle waiting for a build that had already finished.
Speeding up CI is not one trick. It is a prioritized set of moves, and the order matters because the biggest wins usually come from the parts you never measured. Here is the toolkit we reach for, roughly in the order we apply it. The examples are GitHub Actions, but the ideas port to GitLab, CircleCI, or Jenkins with light translation. For more Actions-specific patterns, see the GitHub Actions recipes pillar.
The short answer, if you want it before the detail: profile the critical path, cache dependencies, split the test suite across parallel jobs, and skip work for paths that did not change. Those four account for most of the time in most pipelines, and they are ordered by how much they typically return. Everything after them is refinement.
What "fast enough" actually means
It helps to have a target rather than a vague sense that CI is slow. The thresholds we work to:
- Under 10 minutes for the pipeline that gates a pull request. Past this, people stop waiting for the result and start context-switching, which is the real cost rather than the minutes themselves.
- Under 2 minutes for lint and unit tests, so the fastest signal arrives while the author is still looking at the change.
- Under 30 minutes for the full suite on the main branch, including integration and end-to-end tests that a PR run can reasonably skip.
If your PR pipeline is at 20 minutes or more, the fixes below will usually get you under 10 without new infrastructure. If it is at 60, something structural is wrong, and you are more likely looking at a monolithic test suite or a build that rebuilds from scratch every run.
1. Measure first, guess never
You cannot speed up what you have not timed. Open a recent run and look at the job graph: which jobs run in parallel, which block others, and what the longest path from start to finish actually is. That longest path is your critical path, and it is the only number that determines wall-clock time. Shaving three minutes off a job that already finishes early buys you nothing.
Inside the slow jobs, expand the step timings. GitHub Actions shows per-step duration in the log view. Nine times out of ten the culprits are the same handful: dependency install, the build, and one bloated test step. Write those numbers down. That is your baseline, and everything below is judged against it.
2. Cache dependencies
If your install step reinstalls the world on every run, this is almost always the fastest win available. A cold npm ci or pip install that takes four minutes often drops under 30 seconds when the dependency directory is restored from cache. The setup-* actions have this built in.
We wrote a full walkthrough on getting the cache key right so you get hits instead of silent misses: caching dependencies in GitHub Actions. For the strategy behind what to cache and what not to bother with, see CI pipeline caching that pays off.
Before: every job installs from scratch, four minutes each, three jobs. After: first job populates the cache, the rest restore it in seconds.
3. Parallelize the work
Once caching is in place, look at what is running in series that has no business being sequential. Lint, unit tests, and type checks rarely depend on each other. Split them into independent jobs and they run at once, so the wall-clock cost is the slowest one, not the sum.
For a large test suite, sharding is the lever. A matrix splits the suite across N runners, each running a slice:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx jest --shard=${{ matrix.shard }}/4
Four shards turn a 12-minute test job into roughly three minutes, minus the setup overhead each shard pays. That overhead is why you do not shard into 50 pieces: past a point, setup cost dominates and you are just burning runner minutes.
4. Only run what changed
The fastest job is the one that never runs. Most pushes touch a small slice of the repo, yet many pipelines rebuild and retest everything regardless. Path filters fix that. Trigger a job only when files it cares about actually change:
on:
pull_request:
paths:
- "services/api/**"
- "packages/shared/**"
jobs:
api-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make test-api
In a monorepo, path globs get unwieldy fast, and this is where affected-package detection earns its keep. Tools like Turborepo and Nx build a dependency graph and run tasks only for packages affected by the diff, with turbo run test --filter=...[origin/main] or nx affected --target=test. A change to one leaf package tests that package and its dependents, not the other 40. Combine this with remote task caching and unaffected work returns instantly from cache.
5. Skip redundant work
When someone pushes three times in five minutes, the first two runs are already obsolete. Cancel them. Concurrency groups do exactly this, keeping one run alive per branch and killing the rest:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
On a busy repo this alone can halve the queue during peak hours, because runners stop grinding through work that no one is waiting on. Pair it with fail-fast: true in your matrices so that when one shard fails, the rest stop instead of running to completion just to report the same red X.
6. Reach for faster runners
Hardware is the blunt instrument, so try it after the free wins above. GitHub offers larger hosted runners with more vCPUs and RAM; a build that is genuinely CPU-bound scales close to linearly when you go from 2 cores to 8. You pay per minute at a higher rate, but if the build gets three times faster the math often favors it.
For heavy, sustained workloads, native compiles, big Docker builds, GPU jobs, self-hosted runners on your own hardware change the cost curve entirely and let you keep warm caches on disk. We ran the numbers on when that pays off in self-hosted runners.
7. Shrink the work itself
Everything above makes the same work finish faster. This step questions whether the work needs doing. Build your artifact once, then reuse it across downstream jobs instead of rebuilding in each. Upload it in the build job and download it wherever it is needed:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
e2e:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist
- run: npm run test:e2e
For container builds, Docker layer caching stops you from reinstalling system packages on every image build. Use docker/build-push-action with a registry or GitHub Actions cache backend so unchanged layers are pulled, not rebuilt. And be honest about your tests: a suite full of slow, redundant integration tests hitting real services is often the single biggest line item. Trimming flaky duplicates and pushing coverage down to faster unit tests pays every single run.
8. Reduce checkout and setup overhead
The small stuff adds up when it runs on every job. A shallow checkout (fetch-depth: 1, which is the default) avoids pulling full git history you do not need. Skip installing toolchains a job never uses. Pin action versions so nothing resolves tags at runtime. None of these is dramatic on its own, but across a matrix of a dozen jobs, ten seconds saved per job is two minutes off the bill.
The call we'd make
Start by profiling one real run and finding the critical path, because that tells you which of these moves is worth your afternoon. In practice the order that pays fastest is: cache dependencies, add a concurrency-cancel block, then parallelize your slowest job with a matrix. Those three take an hour and routinely cut a 20-minute pipeline in half. Only after that do path filters, artifact reuse, and bigger runners earn their complexity. Measure again after each change so you know what actually moved the number, and stop when the pipeline is fast enough that the team stops noticing it again.
Using Secrets in GitHub Actions Safely
A practical guide to scoping GitHub Actions secrets correctly, avoiding leaks in logs, blocking fork-PR theft, and preferring OIDC over stored keys.
Best AI Agent Frameworks in 2026 — Compared
A practitioner's comparison of the leading LLM agent frameworks, matching LangGraph, CrewAI, AutoGen and more to real use cases.
More from DevOps
Explore more articles in this category
GitHub Copilot CLI Sandboxing Is GA: What Actually Changed
GitHub made local sandboxing generally available for Copilot CLI on October 7, so the default now restricts the agent instead of trusting it.
The Tensorlake npm Compromise Targeted Your AI Tool Configs
A compromised npm release went after cloud credentials as expected, and local AI coding-assistant config files, which most teams never treated as secrets.
GitLab's New Rate Limits: What to Fix Before Oct 19
GitLab is capping unauthenticated API calls at 60 an hour starting October 19, and the preview windows land before most teams will have noticed.
You might have missed
Evergreen posts worth revisiting.