On this page
How to Speed Up CI Builds (Without Skipping Tests)
Slow pipelines get bypassed. Cut time with dependency caching, parallel jobs, test splitting, and smaller images - not by disabling checks.
Quick answer
- Measure queue time separately from run time; a fast build behind a busy runner pool is still a slow pipeline.
- Dependency caching keyed on the lockfile is the largest single win for most projects.
- Split the test suite across parallel jobs and shard by file or timing data, not arbitrarily.
- Build only what changed: path filters, affected-project detection, and separate lint jobs avoid work that cannot fail.
- Never make CI faster by skipping tests, disabling lint, or using continue-on-error; that converts a slow pipeline into an untrustworthy one.
Where does the time in a CI build actually go?
Look at four numbers before changing anything: queue time, checkout time, dependency install time, and the slowest individual step. Most pipelines are dominated by one of them. If queue time is large, the fix is capacity or concurrency, not caching. If install time is large, a cache helps. If one test file takes eight minutes, splitting the suite helps.
GitHub Actions shows per-step durations in the job log and total job time in the run summary. Export both to compare a change against the previous week rather than against a feeling. A pipeline that went from twelve minutes to nine is progress only if the tests that matter still ran.
How do you cache dependencies correctly?
Key the cache on the lockfile hash, and restore with a prefix so a new lockfile gets the closest compatible cache instead of a cold start. Cache the package manager’s download directory, not node_modules or the virtualenv: installed trees contain absolute paths and platform-specific binaries, so restoring them across runner versions produces subtle breakage.
The same pattern applies to build caches: cache the compiler or bundler’s content-addressed directory, and let the tool decide what is reusable. If the cache key is wrong, the build is slower but correct; if the cached content is wrong, the build is fast and incorrect, which is worse.
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- run: npm ci --prefer-offline --no-auditWhat can run in parallel?
Static checks are independent of each other and of the build: lint, typecheck, and unit tests can run as separate jobs that start together. The build then depends on them, and the deploy depends on the build. That shape gives a fast red signal and keeps the slow jobs from blocking the cheap ones.
Tests can be sharded across runners if the suite is large, but sharding needs a strategy. Splitting by test file with a fixed partition is simple and can be badly unbalanced; splitting by historical timing is better; running one shard per CPU with a dynamic queue is best and most work. Start with two shards and measure the balance before adding more.
What should you not do to make CI faster?
Do not skip the test step on documentation-only changes unless the path filter is precise, because a docs change can still break a build that imports the docs. Do not use continue-on-error to hide a flaky test, because it hides the eventual real failure too. Do not disable the linter to save thirty seconds when the same issues take longer to find in review.
Do not cache secrets, and do not persist a self-hosted workspace to warm a cache. Both make the pipeline faster in a way that turns one compromised job into a persistent problem. The acceptable trade is slower and correct over faster and unverifiable.
Where this bites vibecoders
AI-generated workflows usually run a full install, build, and test on every job, with no cache and no path filter, because that is the shape of the examples it learned from. Copying the cache step from the package manager’s documentation is safe; inventing a cache of node_modules or .venv is where generated pipelines start producing failures that only happen on CI.
Where AI coding assistants get this wrong
- Caching node_modules or .venv instead of the package manager download cache.
- Keying the cache on a branch name, so every branch starts cold.
- Adding continue-on-error to a flaky test instead of fixing or quarantining it.
- Running the full test suite in the deploy job as well as the test job.
- Splitting tests across shards without checking that the shards are balanced.
- Caching ~/.ssh, cloud credentials, or the whole workspace.
Checklist
- Record queue time and run time separately for one week.
- Find the slowest step and fix that one first.
- Cache dependency downloads keyed on the lockfile hash.
- Run lint, typecheck, and unit tests as parallel jobs.
- Shard the suite only after measuring that it dominates.
- Verify the pipeline still fails on a deliberately broken commit.
FAQ
Why is my CI slower than my local build?
Local builds reuse dependency and compiler caches, and often skip checks you run in CI. A hosted runner starts clean, so it downloads and rebuilds everything. Cache the package download directory and the build cache, and compare the remaining gap step by step rather than as a single number.
Does a larger runner always make CI faster?
Only if the job is CPU-bound and parallel. A larger runner does not speed up a sequential install or a single slow integration test. It can help compilation, bundling, and large test suites, but caching and better job shape are usually cheaper wins first.
How do I stop flaky tests from slowing the pipeline?
Quarantine them: move the test to a separate non-blocking job, tag it, and require a fix within a set time. Retrying automatically hides flakiness and doubles the cost. A quarantined test keeps the main pipeline trustworthy while the underlying race or dependency is addressed.
Should I run tests on every commit?
Run the fast, merge-blocking suite on every commit and the slow end-to-end suite on a schedule and before release. The split should follow runtime, not importance. A ten-minute suite on every push gets bypassed; the same suite nightly plus a smoke test on every push does not.
Related topics
- What Are the Stages of a CI/CD Pipeline?
- What Is a CI Runner (and When Do You Need Your Own)?
- Why Does My Build Pass Locally but Fail in CI?
- How to Set Up a CI/CD Pipeline With GitHub Actions
- Unit vs Integration vs End-to-End Tests: What’s the Difference?