Skip to content

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

FixDevs ·

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

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

For related CI/CD issues, see Fix: GitHub Actions Permission Denied and Fix: GitHub Actions Runner Failed.

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