Your CI Is Slow Because of Cache Misses
Photo: Unsplash
A twelve-minute CI run feels like a tax you cannot avoid. You optimize a test here, parallelize a job there, and it barely moves. The reason is almost never raw compute. It is that your pipeline downloads the same dependencies, rebuilds the same layers, and recompiles the same unchanged code on every single run. Your CI is slow because it has no memory.
Caching gives it one. Done right, a cache turns a cold pipeline that fetches the entire internet into a warm one that reuses yesterday's work. Done wrong, you get the worst of both worlds: the time cost of saving and restoring a cache that never actually hits. This article is about getting the hit rate up, because a cache that misses is just overhead.
Why caches miss
A cache is a key-value store. You compute a key, and if an entry exists for that exact key, you get a hit. The entire art is in the key. Make it too specific and it changes on every run, so you always miss. Make it too loose and you restore stale data that breaks the build. The sweet spot is a key derived from the thing that actually determines the cached content, usually a lockfile.
In GitHub Actions, the pattern looks like this:
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-The key hashes the lockfile, so it only changes when dependencies change. The restore-keys is the part teams forget: it is a prefix-matched fallback. If the exact key misses, Actions restores the most recent cache whose key starts with npm-${{ runner.os }}-. So when you add one dependency, instead of fetching everything from scratch, you restore last run's cache and download only the delta. That fallback is the difference between a 95 percent hit rate and a 30 percent one.
The most common cache-miss bug is putting a value that changes every run into the cache key, like a timestamp, a commit SHA, or a run number. The key should change only when the cached content should change. If your key includes the commit SHA, every commit is a guaranteed miss, and you are paying to save caches nobody will ever restore.
Cache the right directory, not the wrong one
A subtle mistake is caching the installed node_modules instead of the package manager's download cache. If you cache node_modules directly and restore it across different lockfiles, you can end up with a half-stale tree that npm ci then has to reconcile, sometimes incorrectly. The safer target is the manager's cache directory (~/.npm, ~/.cache/pip, ~/go/pkg/mod, ~/.gradle/caches), and then let the install step run against a warm cache. The install is fast because nothing needs downloading, and it is still correct because the manager validates against the lockfile.
Many setup actions now do this for you. actions/setup-node with cache: 'npm', actions/setup-python with cache: 'pip', and actions/setup-go all wire up dependency caching with sensible keys automatically. Reach for those before hand-rolling actions/cache, and only customize when you have a non-standard layout.
Docker layer caching in CI is its own problem
CI runners are ephemeral. Each job starts with an empty Docker layer cache, so docker build rebuilds everything from scratch even though only one line of your Dockerfile changed. This is why a build that takes 8 seconds on your laptop takes 4 minutes in CI. The fix is to persist the layer cache externally with BuildKit.
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
push: true
tags: registry.example.com/app:latest
cache-from: type=gha
cache-to: type=gha,mode=maxcache-from and cache-to with type=gha store BuildKit's layer cache in GitHub's cache backend between runs. mode=max caches the layers from every stage, including intermediate build stages, not just the final image, which matters enormously for multi-stage builds where the expensive work happens in stages that never ship. With this in place, an unchanged dependency layer is restored instead of rebuilt, and your CI build approaches local-build speed.
This pairs directly with Dockerfile layer ordering. If your Dockerfile copies source before installing dependencies, the layer cache is useless because the dependency layer is invalidated on every commit. The Docker build documentation covers cache backends and the ordering rules that determine whether any of this helps. Cache infrastructure cannot save a Dockerfile that busts its own cache.
Stop redoing work across jobs
Even with good caching, many pipelines waste time by recomputing the same artifact in multiple jobs. If your test, lint, and build jobs each install dependencies independently, you pay the install cost three times in parallel. Sometimes that is fine because they run concurrently, but when one job produces something the next needs, the right tool is artifacts, not a rebuild:
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
# ...in a later job:
- uses: actions/download-artifact@v4
with:
name: distThe build job compiles once and uploads the result; the deploy job downloads it instead of compiling again. This also guarantees you deploy exactly what you tested, rather than a fresh build that might differ.
Measure before you tune
Do not guess at where the time goes. The single most useful habit is reading the timing breakdown of your runs and finding the longest step. In GitHub Actions, each step shows its duration in the log, and the cache action prints a Cache restored or Cache not found line that tells you immediately whether you are hitting or missing.
# Inspect which steps dominate a run
gh run view <run-id> --log | grep -E "Cache (restored|not found)"If you see Cache not found on a step that should be stable, your key is wrong. If you see a hit but the step is still slow, the cache is restoring but the work is not actually cacheable, and you need a different strategy. Optimize the step that actually dominates the wall-clock time, not the one that is easiest to tweak.
Takeaways
- Slow CI is usually re-downloading and rebuilding unchanged work, not a compute shortage.
- Derive cache keys from lockfiles, and always set
restore-keysfor prefix-matched fallback on near-misses. - Never put a SHA, timestamp, or run number in a cache key; it guarantees a miss every run.
- Cache the package manager's download directory, not installed
node_modules, and prefer setup actions that wire it up for you. - Persist Docker layer cache across ephemeral runners with BuildKit
cache-from/cache-toandmode=max. - Pass artifacts between jobs instead of rebuilding, and read the cache hit/miss logs to find the real bottleneck.

