Skip to content

Fix: GitHub Actions Cache Not Working (Cache Miss on Every Run)

FixDevs · (Updated: )

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 wrong

Or 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 path in the cache action does not match where the tool actually stores its cache.
  • Lock file not committedhashFiles('**/package-lock.json') returns an empty hash if package-lock.json is 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 regenerated

Standard 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-cache

Add 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 cache

pip (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 automatically

Python — 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.txt

Java — 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/foo are only accessible from feature/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 branch

Fix 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: true

This 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 DELETE

Add 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 automatically

Fix 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 way

Common Mistake: Skipping npm ci on cache hit. npm ci does not reinstall packages if they are already in node_modules — but it does check package-lock.json for consistency. Always run npm ci to ensure node_modules is 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 ~/.npm

Verify 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 cache

Check 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles