# How to Speed Up a Slow CI Pipeline

URL: https://www.devopsness.com/blog/speed-up-ci-pipeline
Author: Kiril Urbonas
Published: 2026-07-24
Updated: 2026-09-12
Categories: DevOps
Tags: CI/CD, GitHub Actions, DevOps, Performance, Automation

Summary: A prioritized toolkit for cutting CI time: measure the critical path first, then cache, parallelize, run only what changed, and shrink the work itself.

---
# 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](/blog/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](/blog/github-actions-cache-dependencies). For the strategy behind what to cache and what not to bother with, see [CI pipeline caching that pays off](/blog/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
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
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
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](/blog/self-hosted-runners-cost).

## 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
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.

