The symptom: cache hits on main, misses everywhere else
You open a pull request, the workflow runs, and the log shows this:
Cache not found for input keys: linux-node-a1b2c3d4, linux-node-
But the exact same key, built from the exact same lockfile hash, restored fine on a push to main last week. Nothing about your package-lock.json changed. Your actions/cache@v4 step looks correct. And yet every PR branch starts from zero, re-downloading dependencies or rebuilding from scratch every single time.
This is one of the most common GitHub Actions cache complaints, and it's almost never a typo in your workflow file. It's a direct consequence of how GitHub scopes cache entries to branches, something that isn't well documented and that most teams only discover after wasting hours debugging a key that looks perfectly fine.
First, confirm it's actually a scoping problem
Before you assume branch scoping is the culprit, rule out the more boring causes. Check these in order:
- Key drift: print the resolved key in your logs. A stray whitespace character or an unpinned
hashFiles()glob that includes a generated file will silently change the key between runs. - Path mismatch: the
pathinput inactions/cachemust match exactly between save and restore. A relative path resolved differently on a matrix job will produce a "hit" that restores nothing useful. - Fork PRs: if the PR comes from a fork, GitHub Actions runs it with restricted permissions and it will never see caches from the base repository, by design, for security reasons.
If none of those apply, and the key genuinely matches what worked before, you're dealing with branch scoping.
The root cause: GitHub Actions caches are branch-scoped, not repo-scoped
Here's the part that trips people up: a cache key is not a global lookup across your repository. It's scoped to a hierarchy based on the branch the workflow is running on. A workflow run can only restore a cache entry that was created:
- On the same branch the current run is executing on, or
- On the base branch of the pull request (if this run is a PR check), or
- On the repository's default branch (usually
main)
A cache saved on feature/checkout-flow is invisible to a run on feature/payment-retry, even if both branches would produce the identical key. GitHub doesn't fall back across sibling feature branches at all. It only falls back toward the default branch and the PR's base branch. That's the entire fallback chain — nothing more.
This explains the exact symptom in the error above. If your cache was saved during a run on main, and your PR branch was cut from main before that cache was written, the PR run should see it through the default-branch fallback. But if the cache was only ever saved on a different feature branch — say, a previous PR that never merged — no other branch will ever see it, no matter how identical the key is.
This scoping isn't just a convention layered on top of a generic key-value store — GitHub's cache API resolves keys against its backing storage, and the scoping logic is enforced server-side before your workflow ever sees a hit or miss. GitHub's cache API resolves keys against blob storage hosted on Azure, and the scoping logic is enforced server-side before your workflow ever sees a hit or miss. Your actions/cache step doesn't control this behavior at all — it's baked into the API response itself.
The second cause: immutability, not just scoping
There's a related rule that catches people out even when branch scoping isn't the issue: once a cache entry is written for a given key, it cannot be overwritten. If a later run on the same branch tries to save under the same key, you'll see a warning like Cache already exists and the save is silently skipped, not updated.
This matters for the branch-mismatch investigation because it means the fix isn't "just re-save the cache and it'll sync." If your key doesn't change when your dependencies do, you can get stuck serving a stale cache indefinitely, on top of the branch visibility problem.
The third cause: eviction
GitHub enforces a 10 GB cache limit per repository and evicts entries that haven't been accessed in 7 days, oldest first, once you're over the cap. If your default branch has a busy CI pipeline generating large caches for multiple workflows, your smaller, less-frequently-used feature branch caches can get evicted before you ever open the PR that needed them. It looks identical to a scoping miss in the logs — you just get "Cache not found" either way.
The fix: design keys and restore-keys around the fallback chain, not against it
Stop trying to make every branch cache independently. Lean into the fallback hierarchy instead.
Use restore-keys as a prefix ladder, broad to narrow:
- uses: actions/cache@v4
with:
path: ~/.npm
key: linux-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
linux-node-
The partial match on linux-node- lets a PR branch fall back to the closest cache available in its scope chain, even if the exact hash doesn't match. But remember: this fallback still respects branch scoping. A prefix match on a sibling feature branch's cache still won't be visible.
Make sure your default branch actually warms the cache. If main never runs the same cache-saving step your PR workflows use, there's nothing for feature branches to fall back to. A common pattern is a scheduled or push-triggered workflow on main that rebuilds and saves the cache regularly, specifically so every PR branch has a recent fallback target.
Don't bake the branch name into the cache key. This sounds obvious, but it happens constantly through inherited env vars or matrix strategies that include ${{ github.ref_name }} in a key meant to be shared. If the branch name is in the key, you've defeated the scoping fallback entirely — every branch now needs its own from-scratch cache.
Version your key when the underlying toolchain changes, not just your dependency file:
key: linux-node-v2-${{ hashFiles('**/package-lock.json') }}
Bumping v2 forces a clean cache across all branches at once, avoiding the immutability trap where an old, stale entry blocks a legitimate rebuild.
If you're setting up caching for the first time in a project with no existing conventions, it's worth reading through how to structure a GitHub Actions CI/CD pipeline before you start layering caching on top — key design decisions early on will save you from restructuring workflows later.
Verifying the fix actually worked
Don't just trust that the log says "Cache restored." Confirm the restore is meaningful:
- Check the size of the restored cache in the log output. A near-zero-byte restore that still reports "hit" usually means you restored an empty or unrelated entry.
- Compare install/build time before and after. A real cache hit should shave meaningful time off dependency installation, not just resolve without downloading.
- Inspect the cache list through the API (
gh api repos/:owner/:repo/actions/caches) to see which branch (ref) each cache entry belongs to and confirm your PR branch's base or default branch actually has a recent one. - If you're testing branch-fallback behavior specifically, push a trivial commit to
mainfirst, let it save a fresh cache, then open your PR from a branch cut after that push. That's the only path guaranteed to be visible.
One more thing worth knowing: even a correct cache hit can feel slow to restore on large caches, simply due to network throughput limits on hosted runners. That's a separate, infrastructure-level problem from the scoping issue covered here, but it's often mistaken for the same symptom when a "successful" restore still takes a frustratingly long time.
FAQ
Can I share a cache between two open pull requests off the same base branch? Only indirectly, through the default branch. If PR A saves a cache and PR B is a sibling branch (not a descendant of PR A), PR B cannot see PR A's cache directly. Both can only fall back to whatever was last saved on the shared base or default branch, so the practical fix is making sure your base branch's cache stays fresh.
Why does my cache disappear a few days after I stop working on a branch? GitHub evicts cache entries unused for 7 days, and it evicts oldest-first once your repository crosses the 10 GB total cache limit. Long-lived feature branches that go quiet for a week will lose their cache even if nothing about the key changed, so the next run on that branch will look like a fresh miss.