Fix: GitHub Actions Cache Not Working (Cache Miss on Every Run)
Quick Answer
How to fix GitHub Actions cache not restoring — why actions/cache always misses, how to construct correct cache keys, debug cache hits and misses, and optimize caching for npm, pip, and Gradle.
The Error
Your GitHub Actions workflow uses actions/cache but it never restores — every run is a cache miss and dependencies are reinstalled from scratch:
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}The workflow log shows:
Cache not found for input keys: Linux-node-abc123def456...Or the cache is found but the restore step is skipped:
Cache restored successfully
# But node_modules is still empty — the cached path was wrongOr the cache key changes on every run despite no dependency changes:
Run actions/cache@v4
Cache not found for key: Linux-node-1a2b3c... (different hash each time)Why This Happens
GitHub Actions caches are keyed by a string. A cache miss occurs when:
- Cache key changes every run — the key includes a file whose content changes (timestamps, lock files regenerated, etc.).
- Wrong path cached — the
pathin the cache action does not match where the tool actually stores its cache. - Lock file not committed —
hashFiles('**/package-lock.json')returns an empty hash ifpackage-lock.jsonis in.gitignore. - Cache saved on a different branch — cache entries from one branch are not available to other branches (except the default branch, whose cache is accessible to all).
- Cache expired or evicted — GitHub evicts caches not accessed in 7 days, or when the total cache size exceeds 10GB.
- Restore key not set — without
restore-keys, a partial key match cannot restore a previous cache.
Fix 1: Verify and Fix the Cache Key
The cache key must be stable across runs when dependencies have not changed:
Check what hashFiles is hashing:
- name: Debug cache key
run: |
echo "Hash: ${{ hashFiles('**/package-lock.json') }}"
# If this changes every run, the lock file is being regeneratedStandard npm cache key (stable):
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-What makes a good cache key:
# Good — stable, includes OS and lock file hash
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
# Good — includes Node.js version for multi-version setups
key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }}
# Bad — github.sha changes every commit (defeats caching)
key: ${{ runner.os }}-node-${{ github.sha }}
# Bad — no dependency hash — never invalidates when deps change
key: ${{ runner.os }}-node-cacheAdd restore-keys for partial matches:
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
${{ runner.os }}-restore-keys are tried in order if the exact key is not found. A partial match restores the best available cache, and npm ci only installs the diff. The exact key is then saved at the end of the job.
Fix 2: Use the Correct Cache Path
Different tools store their caches in different locations:
npm:
- uses: actions/cache@v4
with:
path: ~/.npm # npm's global cache directory
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
# Note: Do NOT cache node_modules directly — it is not portable across OS/Node versions
# Cache ~/.npm instead and run npm ci to reinstall from the cachepip (Python):
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-Gradle:
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-Maven:
- uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-Cargo (Rust):
- uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}Go modules:
- uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-Fix 3: Use Built-in Setup Actions with Caching
For common ecosystems, use the built-in setup actions that handle caching automatically — they are simpler and less error-prone than manual actions/cache:
Node.js — use setup-node with cache:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # Automatically caches ~/.npm using package-lock.json hash
# cache: 'yarn' # For yarn.lock
# cache: 'pnpm' # For pnpm-lock.yaml
- run: npm ci # Uses the restored cache automaticallyPython — use setup-python with cache:
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip' # Automatically caches pip dependencies
- run: pip install -r requirements.txtJava — use setup-java with cache:
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'gradle' # Or 'maven'Pro Tip: The built-in setup actions use the same cache key strategy as manual
actions/cache, but require less configuration and are maintained by GitHub. Prefer them over manual cache setup for standard ecosystems.
Fix 4: Fix Cache Scoping and Branch Issues
GitHub Actions caches are scoped by branch. The rules:
- Cache entries saved on branch
feature/fooare only accessible fromfeature/foo. - Cache entries saved on the default branch (usually
main) are accessible from all branches. - Pull request workflows can read caches from the base branch but cannot write to it.
Consequence: A PR workflow always misses cache on first run if the cache has never been saved on the base branch for that key.
Fix — prime the cache on main by running the workflow there:
# Ensure your caching workflow runs on push to main, not just on PRs
on:
push:
branches: [main]
pull_request:
branches: [main]Use a fallback restore key that matches the base branch cache:
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm- # Falls back to any npm cache from any branchFix 5: Debug Cache Hits and Misses
Enable debug logging:
Add the secret ACTIONS_STEP_DEBUG with value true in your repository settings (Settings → Secrets → Actions), or add to your workflow:
env:
ACTIONS_STEP_DEBUG: trueThis outputs detailed logs including cache lookup, hit/miss, and upload/download progress.
Check cache entries in the GitHub UI:
Go to your repository → Actions → Caches (in the left sidebar under “Management”). You can see all stored cache entries, their keys, sizes, and last accessed dates.
Delete stale caches via the API:
# List caches for a repo
gh api repos/{owner}/{repo}/actions/caches
# Delete a specific cache by ID
gh api repos/{owner}/{repo}/actions/caches/{cache_id} -X DELETE
# Or delete by key prefix
gh api "repos/{owner}/{repo}/actions/caches?key=Linux-npm-" -X DELETEAdd a cache hit check step:
- uses: actions/cache@v4
id: npm-cache
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
- name: Check cache hit
run: echo "Cache hit: ${{ steps.npm-cache.outputs.cache-hit }}"
- name: Install dependencies
run: npm ci
# npm ci is fast even with cache — it checks .npm cache automaticallyFix 6: Handle cache-hit Output to Skip Redundant Steps
Use the cache-hit output to skip steps when the cache is fully restored:
- uses: actions/cache@v4
id: pip-cache
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
- name: Install Python dependencies
if: steps.pip-cache.outputs.cache-hit != 'true'
run: pip install -r requirements.txt
# Note: For npm ci, always run it even on cache hit
# npm ci uses the cache automatically — it is fast either wayCommon Mistake: Skipping
npm cion cache hit.npm cidoes not reinstall packages if they are already innode_modules— but it does checkpackage-lock.jsonfor consistency. Always runnpm cito ensurenode_modulesis populated correctly from the cache.
Still Not Working?
Confirm the lock file is committed. hashFiles('**/package-lock.json') returns an empty string if the file does not exist or is gitignored — resulting in a constant key with no dependency-based invalidation:
git ls-files package-lock.json # Should show the file
git check-ignore package-lock.json # Should show nothing (not ignored)Check for cache size limits. Individual caches have a 10GB limit per repository. Large caches (e.g., node_modules with native addons, Docker layers) may fail to save:
# Check cache size before saving
- run: du -sh ~/.npmVerify the workflow is not running on a fork. Forks of public repositories have read-only access to the parent’s cache and cannot write new entries. This is a security restriction — expect cache misses on fork PRs.
Try busting the cache manually. Append a version number to the key to force a fresh cache:
key: ${{ runner.os }}-npm-v2-${{ hashFiles('**/package-lock.json') }}
# ^^^— increment this to bust the cacheFor related CI/CD issues, see Fix: GitHub Actions Permission Denied and Fix: GitHub Actions Runner Failed.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: GitHub Actions Environment Variables Not Available Between Steps
How to fix GitHub Actions env vars and outputs not persisting between steps — GITHUB_ENV, GITHUB_OUTPUT, job outputs, and why echo >> $GITHUB_ENV is required.
Fix: GitHub Actions if Condition Not Working (Steps and Jobs Being Skipped or Always Running)
How to fix GitHub Actions if conditions that don't evaluate correctly — why steps are skipped or always run, how to use context expressions, fix boolean checks, and handle job outputs in conditions.
Fix: AWS ECR Authentication Failed (docker login and push Errors)
How to fix AWS ECR authentication errors — no basic auth credentials, token expired, permission denied on push, and how to authenticate correctly from CI/CD pipelines and local development.
Fix: GitHub Actions permission denied (EACCES, 403, or Permission to X denied)
How to fix GitHub Actions permission denied errors caused by GITHUB_TOKEN permissions, checkout issues, artifact access, npm/pip cache, and Docker socket access.