Making CI Caching Work For You
Cover photo by Albert Stoynov on Unsplash
So, you are sitting there watching the clock tick. Your CI pipeline is dragging on, and you are waiting on the same npm install for the hundredth time today. It feels like a massive waste. I’ve been there. You know you should cache, but sometimes it feels like the cure is worse than the disease.
Why does caching break so much?
“But caching is supposed to be easy, why does it always cause weird side effects?” This is the most common complaint I hear. You are right. Caching is fundamentally a trade-off between speed and correctness. If your cache key is too broad, you end up with stale dependencies. If it is too narrow, you lose all your time savings. It is a balancing act.
Here is a simple rule for node modules. Use your lock file as the primary key. If the package-lock.json hasn’t changed, you probably don’t need to reinstall everything.
- name: Cache node modules uses: actions/cache@v3 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node-Is it always worth the complexity?
“I can just keep the build slow, at least it is reliable.” That is a fair point. If you have a tiny project, you might be over-engineering. I admit, if your test suite runs in under thirty seconds, adding cache logic is just more code for you to maintain. Don’t add complexity unless you have a bottleneck. But once your PRs start taking ten minutes to merge, that argument falls apart fast.
How do I handle partial cache hits?
Sometimes you change a single file, but the whole build starts from scratch. That is usually a sign that your cache keys aren’t granular enough. You want to avoid one giant cache blob for the entire application. Split your cache. Maybe separate your dependencies from your build artifacts.
I often see teams trying to cache their entire build folder. That’s usually a bad idea (unless you are using a tool specifically designed for incremental builds like Nx or Turborepo). Instead, cache the specific folders that hold the heavy lifting, like dist or .next.
Do you really need to cache everything?
Think about the cost of a cache miss. If it takes thirty seconds to re-download dependencies, caching might not even be worth the IO overhead. I am often surprised by how many developers try to cache small files that would be faster to just download fresh. It is easy to assume cache is always better, but sometimes hitting the network is cheap (and less buggy).
Here is how I think about it. If the fetch takes longer than a minute, cache it. If it is faster than that, the maintenance of the cache keys is likely going to cost you more time in the long run than the actual network wait.
What about the cleanup?
I have seen too many pipelines fail because the cache grew too large and hit a disk limit. Keep your keys unique and use a rotation policy if your CI provider supports it. If you are just starting, stick to the defaults. Don’t get fancy until you actually see the performance gains in your metrics.
Are you tracking your pipeline duration over time? If you aren’t, all of this is just guessing. Start by measuring, then cache the slowest step first. Everything else is just optimization noise.