Fix: GitHub Actions Cache Not Working (Cache Miss on Every Run)
Part of: Docker, DevOps & Infrastructure
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. The cache action computes that string, queries the cache backend, and either downloads the matching tarball or marks the entry for a post-job save. A cache miss occurs when the computed key does not match any stored entry. That can happen for benign reasons (first run, deliberate invalidation) or for accidental ones (the key is unstable, the lock file is gitignored, the cache was evicted).
The most common root cause is a key that changes when it should not. hashFiles() walks the workspace and hashes the matched files in deterministic order, so the hash is stable only if the file contents are stable. If a generator step rewrites package-lock.json before the cache step runs, the hash changes on every run. The same problem appears when teams use floating tool versions: a workflow that runs npm install before caching pollutes the lock file with platform-specific resolutions.
The second class of failure is path-vs-cache mismatch. The cache action restores a tarball into the path you specified, but it does not verify that the path is what the downstream tool actually uses. If you cache node_modules but run npm ci (which deletes node_modules before installing), the cache is wasted. If you cache ~/.npm but the runner image stores npm’s cache somewhere else, the restore succeeds but produces no measurable speedup.
The third class is scope. GitHub scopes cache entries by branch with one exception: caches saved on the default branch are readable from every branch. So a feature branch that has never run on main always misses on its first PR build. Combine that with GitHub’s 7-day eviction window and the 10GB-per-repo cap, and a low-traffic project can effectively lose its cache between sprints.
Other common causes:
- 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.
In Production: Incident Lens
Cache misses look harmless until you measure them. A 90-second install repeated across a matrix of 12 jobs adds 18 minutes per workflow, and PR throughput collapses behind the queue. The first symptom is almost never an alert about caching — it is a developer in Slack saying “CI feels slow today.” By the time anyone investigates, every open PR has paid the tax for hours.
Blast radius. A broken cache key affects every build on the default branch and every PR opened against it. If your team merges 30 PRs a day and each one runs CI twice (push + rebase), a doubled install step costs your organization several engineer-hours per day. Worse, slow CI changes engineer behavior: people batch fixes, skip the local lint, and merge against a stale base — exactly the failure modes you wrote CI to prevent.
Alert on duration percentiles, not failures. A green build that takes twice as long is the signal. Track the p50 and p95 of total workflow duration per job name and alert when p95 doubles week-over-week. The GitHub Actions REST API exposes timing data per job; ship it to Datadog, Honeycomb, or a self-hosted Prometheus exporter. Also export the cache-hit output as a job-level metric so you can graph hit rate against duration.
Recovery. When duration spikes, do not start guessing. Open the most recent slow run, expand the cache step, and read the key it computed. Compare to the key from the last fast run on the same branch. Ninety percent of the time the diff is one of: the lock file changed (intended), the Node version bumped (intended), or hashFiles() is hashing a generated file that should be in .gitignore (unintended). Roll forward by fixing the source of churn, not by inflating the cache size.
Preventive design. Build cache keys that fail safely. Always include runner.os, the major tool version, and a hash of the lock file in that order. Never include github.sha or any timestamp. Commit your lock file. Pin the setup-node/setup-python action to a major version so its key format does not silently change. If you operate a self-hosted runner fleet, set a budget alarm on cache storage so a single runaway repository cannot push the org over 10GB and start evicting healthy caches across other teams.
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 cacheCheck whether a previous step regenerates the lock file. A npm install step before the cache action will rewrite package-lock.json and change the hash, so the saved key never matches the next run’s restore key. Run git diff package-lock.json after install in CI to confirm the file is byte-identical to the committed version.
Account for self-hosted runner image drift. If you mix GitHub-hosted runners and self-hosted ones in the same workflow, runner.os resolves to the same string (“Linux”) but the underlying glibc, Node binary, and Python ABI may differ. Add runner.arch and a tool-version suffix to the key so caches from one image do not bleed into the other.
Validate the cache size before saving. Caches under a few hundred kilobytes rarely beat a fresh install thanks to download and extraction overhead. Run du -sh on the cache path and skip caching when the directory is trivially small — the cache action’s save is optional via the actions/cache/save separate action when you want surgical control.
For related CI/CD issues, see Fix: GitHub Actions Permission Denied, Fix: GitHub Actions Runner Failed, Fix: GitHub Actions Timeout, and Fix: GitHub Actions Artifacts Not Working.
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 Artifacts Not Working — Upload Fails, Download Empty, or Artifact Not Found
How to fix GitHub Actions artifact issues — upload-artifact path patterns, download-artifact across jobs, retention days, artifact name conflicts, and the v3 to v4 migration.
Fix: GitHub Actions Secret Not Available — Environment Variable Empty in Workflow
How to fix GitHub Actions secrets that appear empty or undefined in workflows — secret scope, fork PR restrictions, environment protection rules, secret names, and OIDC alternatives.
Fix: GitHub Actions Matrix Strategy Not Working — Jobs Not Running or Failing
How to fix GitHub Actions matrix strategy issues — matrix expansion, include/exclude patterns, failing fast, matrix variable access, and dependent jobs with matrix outputs.
Fix: GitHub Actions Docker Build and Push Failing
How to fix GitHub Actions Docker build and push errors — registry authentication, image tagging, layer caching, multi-platform builds, and GHCR vs Docker Hub setup.