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 Cache That Never Hits

I once inherited a repo where every CI run took 9 minutes and 7 of those minutes were npm ci. The team had actions/cache configured “just like the docs” and it never hit. Two hours of digging revealed a single character bug: the cache key referenced package.json instead of package-lock.json, and package.json was generated by another step in the same workflow, so the hash was different on every run. After fixing it, builds dropped to 90 seconds. Cache misses are rarely visible in passing builds; they are visible in the bill at the end of the month. 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)

Quick Reference Before You Dive In

If you arrived here from Google with a slow CI run, the five facts that resolve roughly 90 percent of the cases I have triaged:

  1. The most common single cause is an unstable cache key. hashFiles() is being run against a file (often package.json or package-lock.json) that an earlier step in the same workflow regenerates, so the hash differs on every run. Check what your hash actually is on two consecutive runs before debugging anything else.
  2. Cache scope is per branch. PR builds can read the base branch’s cache, but feature-to-feature sharing is not allowed. A new branch always misses on its first run unless the default branch has primed the cache for that key.
  3. The path: you cache must match where the tool actually stores its cache. ~/.npm is npm’s directory, not node_modules. Caching node_modules directly is brittle across OS and Node versions; cache the tool’s own cache directory and re-run the install step on every job.
  4. For Node / Python / Java / Go, use the cache: input on setup-node, setup-python, setup-java, or setup-go. They handle the key, the path, and restore-keys automatically. I only fall back to hand-written actions/cache for non-standard build directories or cross-job sharing.
  5. GitHub evicts caches not accessed in 7 days, and the total cache per repository is capped at 10GB (older entries evict first beyond that). The usage limits and eviction policy page is the canonical reference. A low-traffic repo can lose its cache between sprints just from inactivity.

The rest of this article walks through each of those in detail, plus the failure modes most other guides skip. The canonical references are the actions/cache repository and the GitHub Actions cache documentation.

How actions/cache Decides to Miss

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 committed: hashFiles('**/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.

When to Use Which Fix

The next six sections cover the fixes in detail. Before diving in, the table below maps your symptom to the specific fix I would reach for first.

Your symptomRecommended fixWhy
Cache key hash changes every run despite no real dependency changeFix 1: stabilize the key against an immutable artifact (committed lock file)Most common single cause; the key is hashing a regenerated file
Cache “hits” but the install step still downloads everythingFix 2: cache the right path (~/.npm, not node_modules)Wrong path means restore succeeds but the tool does not see it
You wrote the cache action by hand for Node / Python / Java / GoFix 3: switch to the setup-* action’s cache: inputLess code, fewer bugs, GitHub-maintained key strategy
PR builds always miss while main builds hitFix 4: ensure the workflow runs on push to main, add fallback restore-keysPR caches read from base branch only
You can’t tell whether the cache is hittingFix 5: enable ACTIONS_STEP_DEBUG, check the Actions UI Caches pageMakes cache lookups explicit in the logs
You want to skip a step entirely when the cache is fully restoredFix 6: use the cache-hit output of actions/cache in an if: conditionStandard pattern for conditional install steps

If multiple rows look like they apply, pick the topmost one. The fixes are ordered roughly from “most common cause” to “diagnostic and optimization patterns.”

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'

My default for any new repo is to reach for the built-in setup-node, setup-python, setup-java actions with their cache: input rather than writing actions/cache by hand. They use the same underlying cache key strategy, they are maintained by GitHub, and they keep the workflow file shorter. I only write manual cache configs when I need something the setup actions do not cover (e.g., caching a build directory across jobs, or sharing cache between matrix variants).

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) per the debug logging documentation, 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

A subtle anti-pattern I see frequently: skipping npm ci when the cache hits, on the theory that node_modules is already populated. That is wrong. npm ci does not reinstall packages that are already correctly in node_modules; it just verifies they match package-lock.json. The verification is fast but essential, because the cache can be from a slightly different lockfile version (especially during dependency bumps mid-PR). I always run npm ci regardless of cache hit. The cost when the cache is correct is milliseconds; the cost when it is wrong is debugging a “works on my machine” CI failure.

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.

Cache Misses I Have Personally Debugged

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.

What Other Tutorials Get Wrong About GitHub Actions Caching

Most cache tutorials list the same fixes but frame them in ways that produce subtle bugs. The gaps I see most often:

They recommend caching node_modules directly. This is the single most common bad advice. node_modules is not portable across operating systems, Node.js versions, or even npm major versions. A cache populated on ubuntu-22.04 with Node 20 can install corrupted binaries on ubuntu-24.04 with Node 22. Cache ~/.npm (npm’s content-addressable cache directory) and re-run npm ci on every job. The install step uses the warm cache and produces a correct node_modules for the current environment.

They suggest skipping npm ci when the cache hits. Tempting because it looks like an optimization, but wrong. npm ci verifies that node_modules matches package-lock.json; the verification is fast and the failure it catches (lockfile drift mid-PR) is one of the most expensive “works on my machine” bugs to debug in CI. Run npm ci unconditionally.

They include github.sha in cache keys. This defeats caching entirely because the SHA changes on every commit. The key is supposed to be stable when dependencies are stable, so the only acceptable hashed inputs are the lock file and major tool versions. Any tutorial that uses github.sha in a cache key has not actually tested whether the cache hits.

They forget restore-keys. Without a fallback, every lock file change is a full cache miss instead of a partial restore. The standard pattern is key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} plus restore-keys: ${{ runner.os }}-npm-, which restores the most recent npm cache on this OS even when the lock file is different. npm ci then only fetches the diff.

They do not mention the 10GB / 7-day eviction policy. A cache that worked yesterday can be evicted today because the repository exceeded the storage limit or because the entry was inactive. Articles that frame caches as permanent send readers chasing nonexistent regressions.

They conflate cache scope with artifact scope. Caches are branch-scoped (base branch readable from PR builds). Artifacts are run-scoped (one workflow run, retained for 90 days by default). Tutorials that use the two interchangeably produce broken cross-job sharing.

Frequently Asked Questions

Why does my GitHub Actions cache never hit?

Almost always one of three causes: (1) the cache key is unstable because hashFiles() is hashing a file regenerated mid-workflow, (2) the cache path does not match where the tool actually stores its cache, or (3) the workflow is running on a branch that has never written this key (and there is no restore-keys fallback). Inspect the actual computed key in two consecutive runs and compare. Fix 1 covers the key issue, Fix 2 covers the path issue, and Fix 4 covers the branch scope issue.

When do GitHub Actions caches expire?

Caches are evicted after 7 days without access (per the usage limits documentation). When the total cache storage for a repository exceeds 10GB, GitHub evicts the least recently used entries to make room. There is no manual extension of the TTL, but accessing a cache (a hit on a workflow run) resets its 7-day timer.

What is the cache size limit per repository?

10GB total per repository. Individual cache entries can be up to 10GB themselves, but in practice most fail to save above 2-3GB due to compression and upload timeouts. Track your usage in repository settings, Actions section, Caches. Aggressive matrix builds and large image dependencies (ML models, large native binaries) hit this limit fastest.

Are caches shared between branches?

Partially. Caches written on the default branch (usually main) are readable from every branch. Caches written on a feature branch are only readable from that same branch. Pull request workflows can read the base branch’s cache but cannot write entries that other branches will see. The practical consequence: prime your cache by running the workflow on push to main, not just on PRs.

Can fork PRs use the parent repository’s cache?

Forks have read-only access to the parent repository’s cache and cannot write new entries. This is a security restriction to prevent malicious PRs from poisoning the cache. Expect cache misses on first runs of fork PRs and avoid relying on cached state for security-sensitive checks.

Should I cache node_modules or ~/.npm?

~/.npm. The npm content-addressable cache is portable across runs and stable across small workflow changes. node_modules is not portable across OS or Node versions, and a cached node_modules from a slightly different lockfile can install corrupted state. Cache ~/.npm, run npm ci on every job, and let npm reconcile node_modules against the lock file each time. The cost of npm ci against a warm npm cache is measured in seconds, not minutes.

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