🚀 GitHub Actions Caching: Stop Watching the Progress Bar Spin
We've all been there. You push a tiny one-line bug fix, open a coffee, watch the GitHub Actions run tick from 0%… 2%… 4%… and then the Slack notification rolls in from your teammate: "Is CI broken again?"
No. CI is not broken. It's just spending eight of your twelve minutes downloading the same node_modules folder it downloaded yesterday. And the day before. And every day for the past six months.
This is the DevOps equivalent of re-reading the entire instruction manual before using your microwave each morning.
Let's fix it.
Why Caching Matters (More Than You Think)
GitHub Actions runs every job in a fresh, ephemeral VM. That's great for reproducibility — but terrible for your patience and your bill. Every minute of CI time costs money, and every install step is burning both.
Here's a real-world baseline from a medium-sized Node.js project:
| Step | Without Cache | With Cache |
|---|---|---|
npm ci |
4m 30s | 12s |
| Docker layer pulls | 3m 15s | 40s |
| Total pipeline | 11m 20s | 3m 10s |
That's 8 minutes saved per run. If your team pushes 20 times a day, that's 2.5 hours of human (and machine) time handed back to you daily. Go touch grass. Or write more features. Your call.
The Basics: Caching npm Dependencies
The actions/cache action is your best friend here. The key is generating a cache key from your lockfile so the cache automatically busts when your dependencies actually change.
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # <-- built-in shorthand, does the heavy lifting
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
The cache: 'npm' option on setup-node is the lazy (smart) path. It automatically caches ~/.npm using package-lock.json as the cache key. Change the lockfile → new cache. Same lockfile → cache hit. Elegant.
But what if you need more control? Maybe you're caching a custom tool, build artifacts, or you're not using Node at all?
Going Deeper: Manual Cache Control
Here's a more explicit setup using actions/cache directly, which gives you full control over paths and key strategies:
- name: Cache node_modules
uses: actions/cache@v4
id: npm-cache
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
if: steps.npm-cache.outputs.cache-hit != 'true'
run: npm ci
A few things worth calling out here:
The key uses hashFiles to fingerprint your lockfile. The moment you npm install something new, the hash changes, the old cache is bypassed, and a fresh one is saved at the end of the run.
The restore-keys is your fallback. If the exact key isn't found, GitHub will grab the most recent cache that starts with ${{ runner.os }}-npm-. This is called a partial cache restore — you still get most of your packages cached, and only the new additions get downloaded. Much better than starting cold.
The if: condition skips the install entirely on a full cache hit. Zero network calls. Maximum smugness.
Real-World Lesson: The Cache Poisoning Trap
Here's something that'll bite you eventually: don't cache your node_modules directory directly.
# ❌ Don't do this
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }}
Why? Native addons (like bcrypt, sharp, or canvas) get compiled for the host OS during install. If you cache the compiled binaries and GitHub switches your runner from one Ubuntu patch to another, you'll get cryptic runtime errors that have nothing to do with your code.
Cache ~/.npm (the download cache) instead, and let npm ci do the linking step locally. It's fast because the tarballs are already there — and the compilation is always fresh for the current environment.
The lesson: cache the inputs, not the outputs when the outputs depend on the environment.
Bonus: Caching Docker Layers in Your Workflow
If your pipeline builds Docker images, you're probably pulling the same base layers on every run. GitHub's docker/build-push-action has first-class support for layer caching via the GitHub Actions cache backend:
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=max
type=gha tells BuildKit to use GitHub Actions cache as the layer store. mode=max caches every layer, not just the final one. The result: if only your app code changed and your apt-get layer is identical, that layer is restored from cache in seconds.
The first run after adding this will feel slow — it's warming the cache. The second run will make you smile.
Quick Wins Checklist
Before you call it done, run through these:
- Are you caching your package manager's download cache (
~/.npm,~/.cache/pip,~/.gradle/caches)? - Is your cache key based on a lockfile hash, not a date or branch name?
- Do you have
restore-keysfor partial cache fallback? - Are you using
cache: 'npm'/cache: 'pip'built-ins where available? They're well-maintained and handle edge cases. - Are Docker image builds using
cache-from: type=gha?
The Takeaway
Slow CI is a tax on developer happiness. Every minute your engineers spend watching a spinner is a minute they're not in flow, not shipping, and probably opening Twitter instead.
GitHub Actions caching is one of the highest ROI improvements you can make to your pipeline with almost zero risk. It takes 15 minutes to set up and pays dividends every single day.
Go cache something. Your future self (and your colleagues) will thank you.
Have a caching war story — a key collision, a poisoned cache, a partial restore that saved your deploy? Drop it in the comments. The DevOps struggle is real and shared.