GitHub Actions Tutorial — Cache Strategies
Cache Strategies
A good cache key strategy maximizes cache hit rates while ensuring stale caches don’t cause issues. This page covers patterns for designing effective cache keys and understanding cache behavior.
Cache Key Design
Exact Match Key
key: node-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
This key changes only when dependencies change. Perfect for dependency caches.
Restore Keys (Fallback)
When the exact key misses, restore-keys tries prefix matches:
key: node-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
node-${{ runner.os }}-
node-
Match order:
- Exact match on
key→ perfect hit - Prefix match on
node-Linux-→ partial hit (most recent) - Prefix match on
node-→ broader fallback
A partial hit restores an older cache. Your install command updates the difference.
Branch-Based Caching
Cache behavior across branches:
main branch:
├── Cache: node-Linux-abc123 (from main)
│
feature branch:
├── Can READ main's cache (restore-keys fallback)
├── Creates own cache: node-Linux-def456
│
main branch:
└── Cannot read feature branch's cache
Rules:
- Feature branches can read the default branch’s cache
- Feature branches can read their own cache
- The default branch can only read its own cache
- Pull requests follow the feature branch rules
Cache Busting
Sometimes you need to force a fresh cache:
Method 1 — Change Prefix
# Before
key: v1-node-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
# After (busted)
key: v2-node-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
Method 2 — Include Date
key: node-${{ runner.os }}-week${{ github.run_number }}-${{ hashFiles('package-lock.json') }}
Cache Eviction
GitHub evicts caches when:
| Condition | Behavior |
|---|---|
| Not accessed for 7 days | Auto-deleted |
| Repository cache > 10 GB | Oldest caches evicted first |
| Manual deletion | Via GitHub API or CLI |
View and Manage Caches
# List caches
gh cache list
# Delete a specific cache
gh cache delete <cache-id>
# Delete all caches
gh cache delete --all
Multi-Key Strategy
For projects with multiple dependency types:
steps:
- name: Cache npm
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: npm-${{ runner.os }}-
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('requirements.txt') }}
restore-keys: pip-${{ runner.os }}-
- name: Cache build output
uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-
nextjs-${{ runner.os }}-
Cache vs Install Timing
| Scenario | npm ci Time | With Cache |
|---|---|---|
| No cache | ~60s | ~60s |
| Cache hit (npm global) | ~60s | ~20s |
| Cache hit (node_modules) | ~60s | ~5s |
Caching node_modules directly is faster but riskier (can become stale). Caching ~/.npm is safer — npm ci still runs but skips downloads.
Common Mistakes
| Mistake | Fix |
|---|---|
| Cache never hits on PRs | PRs can read the default branch cache via restore-keys
|
| Cache is stale | Include lock file hash in key; stale deps get replaced |
| Cache too large (>10 GB) | Cache only what’s needed; exclude build artifacts |
| Same key across OS | Include runner.os in the key |
Quick Reference
| Strategy | Key Pattern |
|---|---|
| Standard | prefix-${{ runner.os }}-${{ hashFiles('lockfile') }} |
| With version | v2-prefix-${{ runner.os }}-${{ hashFiles('lockfile') }} |
| Build cache | build-${{ runner.os }}-${{ hashFiles('lockfile') }}-${{ hashFiles('src/**') }} |
| Restore fallback | Most specific → least specific prefixes |
| Cache expiry | 7 days since last access |
| Max per repo | 10 GB total |
← Previous: Dependency Caching
Next: Monorepo Caching →