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:
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:
- A cache is scoped by its key, an internal cache version, and the branch or tag ref that created it.
- A run can restore caches from its current ref and the default branch. A pull request run can also access its base branch.
- A run cannot restore a cache created for a sibling branch.
- A cache created by a
pull_requestworkflow belongs to the pull request's merge ref,refs/pull/.../merge, and is only reusable by reruns of that pull request. restore-keysbroadens 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 order | Scope | Match |
|---|---|---|
| 1 | Current branch or pull request merge ref | Exact primary key |
| 2 | Current branch or pull request merge ref | Prefix match for the primary key |
| 3 | Current branch or pull request merge ref | Each restore-keys entry, most specific first |
| 4 | Default branch | Exact primary key |
| 5 | Default branch | Prefix match for the primary key |
| 6 | Default branch | Each 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:
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-flowIf 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:
refs/pull/123/mergeThat 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.

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:
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:
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 archiveThe 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:
Available only on sibling branch: feature/payment-retry
Current pull request: feature/checkout-flow
Result: inaccessible, regardless of prefixThat 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:
- 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 ciThree 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 cialways 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:
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 testGitHub also recommends setup-node's built-in package-manager caching when you do not need manual control over the cache action:
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- run: npm ciThe 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:
Without restored ~/.npm:
registry/network → npm cache → node_modules
With restored ~/.npm:
restored npm cache → node_modulesThe second path is usually faster, but both paths still run npm ci.
I do not write this when the cached path is ~/.npm:
- if: steps.npm-cache.outputs.cache-hit != 'true'
run: npm ciAn 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 citake 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:
- 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.

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:
Repository → Actions → CachesGitHub 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:
- I keep one trusted workflow running on pushes to
mainso the default-branch cache remains current. - I use a lockfile hash in the primary key for deterministic exact matches.
- I add a stable, scoped restore prefix for useful fallback when the lockfile changes.
- I do not include the feature branch name in the key unless branch-specific content genuinely requires it. GitHub already applies a ref scope.
- I keep
npm ciunconditional when caching~/.npm. - I inspect cache entries by ref before blaming
hashFiles(). - Delete closed pull request caches on a deliberate cadence if they cause storage churn; GitHub documents a
gh cachecleanup 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:
- Did
actions/checkoutrun beforehashFiles()? - Does
hashFiles('**/package-lock.json')match the lockfiles you expect? - Is the cache path the same as the path used when the entry was created?
- Is the runner operating system compatible with the archive?
- Was the cache created on the current ref, default branch, or pull request base branch?
- Was it created on a sibling branch that the current run cannot access?
- Was it created by another pull request under a different
refs/pull/.../mergeref? - Did an exact primary key miss but a
restore-keysprefix restore a useful archive? - Is the default-branch cache actually being refreshed by trusted pushes to
main? - Are pull request caches consuming storage and evicting the entries you expected to reuse?
The key matters. It is simply not the whole lookup.