How to Speed Up a Slow CI Pipeline

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.

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:

yaml.yaml

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:

yaml.yaml

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:

yaml.yaml

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:

yaml.yaml

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.