The Pipeline Slowdown Problem
We've all watched a CI/CD pipeline crawl through dependency resolution, compilation, and test setup—sometimes taking 10–15 minutes for a workflow that should finish in 2. The culprit is rarely the code itself; it's the repeated download and installation of the same dependencies, node modules, or compiled artifacts on every run. Caching in GitHub Actions: practical tips and trade-offs is about reclaiming that lost time while staying aware of when caching helps versus when it creates fragile, hard-to-debug builds.
Setup-* Actions Have Built-in Caching
The popular setup actions—setup-node, setup-python, setup-dotnet, setup-java—come with caching baked in. You don't need to wire up actions/cache manually; just enable the feature with a single flag. This is the easiest win for most teams.
Let's say your workflow installs Node.js and builds a Next.js application. Without caching, npm install might take 45 seconds on every push. With it, that drops to a few seconds after the first run in a given week.
name: Build and Test
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
- run: npm testThat cache: 'npm' line is the key. GitHub Actions will automatically hash your package-lock.json, restore cached node_modules if the hash matches, and upload any changes after the job completes. The same pattern works for yarn (use cache: 'yarn') and pnpm (use cache: 'pnpm').
For Python, enable it with cache: 'pip'. For .NET, use cache: 'nuget'. Each setup action knows what to cache and where to find it. The trade-off here is minimal: you get speed almost for free, and the only real risk is if your lock file becomes stale—but that's a workflow discipline issue, not a caching issue.
Actions/cache for Custom Paths
Sometimes you need to cache things outside the standard dependency folders. Maybe you're caching compiled Go binaries, build outputs, Docker layer caches, or intermediate artifacts from a multi-stage workflow. That's where actions/cache comes in.
Here's a real scenario: you build a CLI tool in Go, run tests, then package it. The binary build takes 90 seconds. If you cache the GOPATH or build cache, subsequent runs drop to 10 seconds.
name: Build Go CLI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:
go-version: '1.22'
- uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- run: go build -o cli ./cmd/cli
- run: go test ./...
- run: ./cli versionThe path field specifies what to cache—usually build artifacts or dependency folders. The key is critical: it's a unique identifier for this cache entry. If the key matches a previous run, the cache is restored. If not, a new entry is created. The restore-keys fallback lets you use a partial match; if the exact key doesn't exist, GitHub looks for any key that starts with the restore-keys prefix.
The trade-off here is complexity and size. Each cache entry has a 5 GB per-repository limit across all branches. If you're not careful, you can fill that quota and start evicting useful caches. Also, stale cached artifacts can hide real problems—a cached binary might work locally but fail in production if a dependency was quietly broken.
Key Caches by Lock File Hash
The most reliable caching strategy ties your cache key to the content of your dependency lock file (or equivalent). This ensures that when dependencies change, the cache is automatically invalidated and rebuilt. It's the principle behind the built-in caching in setup actions, and it's equally important when using actions/cache manually.
In a Node.js project, use hashFiles('**/package-lock.json'). In Python, hashFiles('**/requirements.txt') or **/Pipfile.lock. In Go, hashFiles('**/go.sum'). The hash is computed from the file content, so any change to dependencies triggers a cache miss and a fresh install.
- uses: actions/cache@v4
with:
path: |
node_modules
dist
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-Why not just use a static key like my-cache-v1? Because then you have to manually update it whenever dependencies change, and you'll likely forget. Or worse, you'll get a stale cache that breaks the build silently. The hash-based approach is self-healing: it automatically detects changes without ceremony.
The downside is that if you're debugging a flaky build, understanding cache keys can be tricky. Always include the restore-keys fallback so that even if there's no exact match, the workflow doesn't fail—it just does a fresh install and caches the result for next time.
Measure Before and After
Caching feels good in theory, but you need data to justify the complexity. Before adding cache, note the average job duration. After enabling it, measure again over at least 5–10 runs to smooth out noise (network variance, runner load, etc.).
GitHub Actions provides timing data in the job logs. Look for the step duration, not wall-clock time (which includes queuing). Most teams see 30–60% improvement on the first cache hit and sustained improvement on subsequent runs.
| Scenario | Without Cache | With Cache (First Hit) | Speedup |
|---|---|---|---|
| Node.js (npm ci + build) | 60s | 15s | 4x |
| Python (pip install + tests) | 45s | 8s | 5.6x |
| Go (build cache) | 90s | 10s | 9x |
| .NET (NuGet restore) | 75s | 12s | 6.25x |
These are typical numbers from production e-commerce and SaaS workflows. Your mileage varies based on dependency count, network speed, and runner specs. The key point: measure your own pipeline. If npm ci takes 5 seconds already, caching it won't move the needle. Focus on the slowest step.
Use GitHub Actions' built-in timing information in the workflow logs. Click on each step to see the duration. Then enable caching, run a few times, and compare. If you see consistent 2x+ improvement, it's worth keeping. If it's marginal (10–15% faster) but adds mental overhead, consider whether the tradeoff is worth it.
Common Mistakes
- Caching without a proper key strategy. Using a static key like
cache-v1defeats the purpose. When dependencies update, you'll get a cache miss anyway, or worse, a stale cache. Always hash your lock file or usehashFiles(). - Caching too much. A cache entry that's 2+ GB takes time to upload and download. Cache only what's slow to rebuild. Don't cache dist folders unless they're genuinely expensive to generate.
- Ignoring cache misses on first PR runs. The first time a PR runs, there's no cache. The workflow will be slow. That's normal and expected. Don't panic or add unnecessary steps to work around it.
- Trusting stale caches without validation. If you cache a built artifact (a binary, a compiled library), add a checksum or version step afterward to confirm it's not corrupted. A stale cache can mask real breakage.
- Not cleaning up old cache entries. GitHub automatically evicts the least-recently-used entries when you hit the 5 GB limit, but it's worth monitoring. If you're cycling through cache keys rapidly (e.g., different lock file hashes on every commit), you might evict useful entries.
Key Takeaways
- Use the built-in caching in
setup-node,setup-python, and other setup actions first. It's the easiest win with minimal configuration. - For custom paths, use
actions/cachewith a key derived from your lock file hash (hashFiles()) to ensure automatic cache invalidation when dependencies change. - Always include a
restore-keysfallback to handle cache misses gracefully without breaking the workflow. - Measure the before and after timings over multiple runs. A consistent 2x+ speedup justifies the added configuration; marginal gains may not be worth the complexity.
- Be aware of the 5 GB per-repository cache limit and avoid caching large, frequently-changing artifacts. Cache dependencies and build outputs, not entire node_modules folders unless you really need to.
- Include validation steps (checksums, version checks) when caching compiled artifacts to catch stale or corrupted cache entries early.






Comments
Loading comments…