Cloud Tech

Your GitHub Actions Cache Hit Rate Is Worse Than You Think, and the Key Isn't the Problem

Problem this article addresses

Why identical GitHub Actions cache keys still miss across pull requests, how branch scope and restore keys work, and the correct npm cache YAML.

Published Jul 26, 2026Victor NwokeReviewed Jul 26, 202610 min read

Technical claims are reviewed against the cited primary sources. Hands-on guides include execution or diagnostic evidence when the article makes a tested-result claim.

A team I worked with added actions/cache to a Node.js build and expected most CI runs to stop paying the full dependency-install cost.

The cache key looked right:

yaml
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}

The lockfile hash was stable. The operating system matched. The workflow logs still showed far more misses than expected.

The missing part of the mental model was not the hash. GitHub Actions caches are scoped by key, cache version, and Git ref. Two pull requests can calculate the same key and still be unable to read each other's caches.

There was a second misconception too: caching ~/.npm does not replace npm ci. It restores npm's download cache, so npm ci can fetch less data from the registry. The install step still has to build a clean node_modules tree.

Those two details explain a surprising number of disappointing cache dashboards.

If you are designing the rest of the workflow, start with CI/CD Pipelines. For the trust boundary around pull requests and cached files, also read Secure CI/CD Pipelines.

The Short Version

GitHub's official dependency-caching documentation establishes five rules that matter here:

  1. A cache is scoped by its key, an internal cache version, and the branch or tag ref that created it.
  2. A run can restore caches from its current ref and the default branch. A pull request run can also access its base branch.
  3. A run cannot restore a cache created for a sibling branch.
  4. A cache created by a pull_request workflow belongs to the pull request's merge ref, refs/pull/.../merge, and is only reusable by reruns of that pull request.
  5. restore-keys broadens the key search inside accessible scopes. It does not bypass the scope boundary.

Same key does not mean globally shared cache

The key is only one part of cache identity. An identical key on two sibling feature branches does not make either branch's cache visible to the other.

What GitHub Searches

For a pull request from feature/checkout-flow into main, GitHub searches for the primary key and compatible cache version in the current scope first. If it cannot find a usable entry, it tries prefix matches and then each restore-keys prefix in order. If that still finds nothing, it repeats the search in the default branch's scope.

GitHub documents the effective priority like this:

Search orderScopeMatch
1Current branch or pull request merge refExact primary key
2Current branch or pull request merge refPrefix match for the primary key
3Current branch or pull request merge refEach restore-keys entry, most specific first
4Default branchExact primary key
5Default branchPrefix match for the primary key
6Default branchEach restore-keys entry, most specific first

For a pull request whose base is not the default branch, that base branch is another eligible scope. The important word is eligible: the matching algorithm never turns a sibling branch into an eligible scope.

This is the branch relationship:

text
main
├── feature/checkout-flow
└── feature/payment-retry

feature/checkout-flow can restore:
  ✓ its own accessible cache scope
  ✓ main
  ✗ feature/payment-retry

feature/payment-retry can restore:
  ✓ its own accessible cache scope
  ✓ main
  ✗ feature/checkout-flow

If both feature branches have the same package-lock.json, either can get an exact hit from a matching cache on main. They still cannot share the cache one sibling created.

Pull Request Caches Are Even Narrower

The pull_request event has one extra detail that is easy to miss.

GitHub creates that run's cache for the synthetic merge ref:

text
refs/pull/123/merge

That cache can be restored by reruns of pull request 123. It cannot be restored by main, and it cannot be restored by pull request 124 even when both pull requests produce the same key.

GitHub CLI output showing identical pnpm cache keys stored separately under main and pull request merge refs

Real cache entries from Cloud Tech by Victor: identical pnpm keys exist separately under refs/heads/main and individual refs/pull/.../merge scopes.

This is why a repository with many open pull requests can accumulate many similar cache entries. GitHub's cache-management documentation explicitly describes this pattern and warns that branch-limited pull request caches can consume enough repository cache storage to evict more useful default-branch entries.

The default branch therefore matters more than any individual feature branch. A trusted workflow triggered by a push to main should execute the same cache step and keep that shared base warm.

What restore-keys Actually Does

restore-keys is an ordered list of fallback prefixes.

Given:

yaml
key: Linux-npm-v1-a1b2c3
restore-keys: |
  Linux-npm-v1-
  Linux-npm-

GitHub first looks for the exact primary key. If it cannot find one, it checks the restore prefixes in order. When multiple caches match a prefix, GitHub restores the most recently created matching cache.

The phrase "partial match" describes the key comparison. GitHub still restores the selected cache archive; it does not merge fragments from several caches or restore only part of one archive.

Restore keys help when the lockfile changes:

text
Exact key requested: Linux-npm-v1-new-lockfile-hash
Available on main:  Linux-npm-v1-old-lockfile-hash
Restore prefix:     Linux-npm-v1-
Result:             restore the most recent matching archive

The restored npm cache may already contain most of the package tarballs needed by the new lockfile, so npm ci downloads only what is missing.

Restore keys do not help with this:

text
Available only on sibling branch: feature/payment-retry
Current pull request:             feature/checkout-flow
Result:                           inaccessible, regardless of prefix

That distinction is the heart of the problem. A broader prefix improves fallback matching; it does not create cross-branch sharing.

Correct YAML for npm

For an Ubuntu runner, this is a sound manual configuration:

yaml
- name: Cache npm download data
  id: npm-cache
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-v1-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-v1-

- name: Install dependencies
  run: npm ci

Three details are deliberate:

  • The workflow caches ~/.npm, so the step is named "Cache npm download data," not "Cache node modules."
  • The version marker, v1, gives you an intentional way to invalidate the whole key family later.
  • npm ci always runs. A restored npm cache accelerates package retrieval; it does not create the installed dependency tree.

The checkout step must run before this cache step. GitHub's hashFiles() function only hashes files inside GITHUB_WORKSPACE, and it returns an empty string when the pattern matches no files. If the repository has not been checked out yet, every run can silently collapse to a key ending in npm-v1-.

A complete sequence therefore looks like:

yaml
steps:
  - uses: actions/checkout@v6

  - uses: actions/setup-node@v6
    with:
      node-version: '24'

  - name: Cache npm download data
    id: npm-cache
    uses: actions/cache@v4
    with:
      path: ~/.npm
      key: ${{ runner.os }}-npm-v1-${{ hashFiles('**/package-lock.json') }}
      restore-keys: |
        ${{ runner.os }}-npm-v1-

  - run: npm ci
  - run: npm test

GitHub also recommends setup-node's built-in package-manager caching when you do not need manual control over the cache action:

yaml
- uses: actions/setup-node@v6
  with:
    node-version: '24'
    cache: 'npm'
    cache-dependency-path: '**/package-lock.json'

- run: npm ci

The setup-node documentation is explicit that this caches global package-manager data and does not cache node_modules.

Why You Must Not Skip npm ci

On Linux and macOS, npm's cache defaults to ~/.npm. It is a content-addressable store for downloaded package data.

node_modules is the installed project dependency tree. It is a different directory with a different purpose.

The official npm documentation says npm ci installs the whole project from its lockfile and removes an existing node_modules directory before installation. Restoring ~/.npm therefore changes where package data comes from, not whether installation happens:

text
Without restored ~/.npm:
registry/network → npm cache → node_modules

With restored ~/.npm:
restored npm cache → node_modules

The second path is usually faster, but both paths still run npm ci.

I do not write this when the cached path is ~/.npm:

yaml
- if: steps.npm-cache.outputs.cache-hit != 'true'
  run: npm ci

An exact cache hit would skip installation on a fresh GitHub-hosted runner, leaving the project without node_modules.

Your Cache-Hit Metric May Be Misleading

The cache-hit output reports whether the primary key found an exact match. A restore through a fallback prefix is useful reuse, but it is not an exact primary-key hit.

That means these are different operational questions:

  • Did the primary key match exactly?
  • Did the action restore a fallback cache?
  • How long did npm ci take after the restore?
  • How much data did npm still download?

Tracking only exact hits can make a healthy fallback strategy look ineffective. Tracking only job duration can hide a growing number of cold pull request caches.

At minimum, give the cache step an id and expose the exact-hit signal in the log:

yaml
- name: Report cache result
  run: echo "exact-cache-hit=${{ steps.npm-cache.outputs.cache-hit }}"

Then I I compare that value with the cache action's restore log and the duration of npm ci.

GitHub Actions workflow log showing an exact pnpm cache hit followed by a successful restore from the primary key

A real CI run restoring a 203 MB pnpm cache from an exact primary-key match. Package-manager caching still leaves the dependency-install step responsible for creating the project dependency tree.

You can inspect the repository's actual cache entries under:

text
Repository → Actions → Caches

GitHub lets you filter that list by branch or search across branches with key: key-name. For pull request caches, the underlying ref has the form refs/pull/<number>/merge.

What to Change in a Busy Repository

I use this order:

  1. I keep one trusted workflow running on pushes to main so the default-branch cache remains current.
  2. I use a lockfile hash in the primary key for deterministic exact matches.
  3. I add a stable, scoped restore prefix for useful fallback when the lockfile changes.
  4. I do not include the feature branch name in the key unless branch-specific content genuinely requires it. GitHub already applies a ref scope.
  5. I keep npm ci unconditional when caching ~/.npm.
  6. I inspect cache entries by ref before blaming hashFiles().
  7. Delete closed pull request caches on a deliberate cadence if they cause storage churn; GitHub documents a gh cache cleanup workflow for this case.

Merging main into a long-running feature branch can make its lockfile hash line up with the latest default-branch cache. That can improve the chance of an exact match. It does not change which branches the workflow is allowed to read.

Security Boundary, Not a Trust Guarantee

GitHub describes branch and tag restrictions as a logical access boundary that supports cache isolation and security. That does not make restored cache contents trusted.

GitHub warns that anyone who can open a pull request may be able to read caches from the base branch. It also warns that cache contents are not signed or verified and may contain files that later workflow steps execute.

Follow two rules:

  • I never put secrets, tokens, credentials, or other sensitive files in a cache path.
  • Treat restored files as untrusted input, especially in workflows that execute generated binaries or scripts.

The branch boundary limits which entries a run can access. It does not validate the files inside an accessible entry.

Troubleshooting Checklist

When an apparently correct key keeps missing, check these in order:

  1. Did actions/checkout run before hashFiles()?
  2. Does hashFiles('**/package-lock.json') match the lockfiles you expect?
  3. Is the cache path the same as the path used when the entry was created?
  4. Is the runner operating system compatible with the archive?
  5. Was the cache created on the current ref, default branch, or pull request base branch?
  6. Was it created on a sibling branch that the current run cannot access?
  7. Was it created by another pull request under a different refs/pull/.../merge ref?
  8. Did an exact primary key miss but a restore-keys prefix restore a useful archive?
  9. Is the default-branch cache actually being refreshed by trusted pushes to main?
  10. Are pull request caches consuming storage and evicting the entries you expected to reuse?

The key matters. It is simply not the whole lookup.

Official Documentation

ShareXLinkedInReddit
Was this page helpful?
Suggest an improvement