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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Explore more articles in this category
Run only the CI jobs a change actually affects using if conditionals, trigger path filters, and per-job path detection in a monorepo.
Stop stashing long-lived AWS access keys in GitHub secrets and let OIDC hand your workflows short-lived, scoped credentials instead.
Most GitHub Actions pain comes from the same handful of jobs done wrong. This is the map: the recipes that make pipelines fast, secure, and cheap.
Evergreen posts worth revisiting.