DEV Community

Libme
Libme

Posted on

GitHub Actions Cache Never Hits on Pull Requests: Branch Scope, Immutable Keys, and restore-keys

If actions/cache reports "Cache not found" on every pull request even though you can see caches in the repo's Actions tab, the cause is almost always one of three things: the cache was saved on a branch your PR cannot read, the key is identical to an existing one (caches are immutable, so the new content is never saved), or your restore-keys prefix does not match anything. All three are fixable in a few lines of YAML, and you can diagnose which one you have from the job log alone.

What does the failure actually look like?

The symptoms I see most often in logs (wording as of late 2026; the exact text may change between releases):

Cache not found for input keys: Linux-npm-3f1a9c..., Linux-npm-
Enter fullscreen mode Exit fullscreen mode

and, in the post-job step:

Warning: Failed to save: Unable to reserve cache with key Linux-npm-3f1a9c..., another job may be creating this cache.
Enter fullscreen mode Exit fullscreen mode

The first line means a restore found nothing. The second means a save was refused because that exact key already exists. People often see both in the same repo and conclude the feature is flaky. It is not. It follows three deterministic rules.

Takeaway: a cache miss is never random; it is one of three rules, and the log tells you which.

Why can't my PR see the cache that main built?

Caches are scoped to a branch (more precisely, to a git ref). According to GitHub's documentation, a workflow run can restore caches created on its own ref, on the base branch of a pull request, and on the default branch. It cannot read caches created by a sibling branch or a different PR.

That has two practical consequences:

  1. If your workflow only runs on pull_request and never on pushes to main, then nothing ever populates the default-branch cache. Every PR starts cold, forever, and each PR writes a cache that no other PR can read.
  2. Two feature branches never share caches with each other. They both fall back to main's cache, if one exists.

The fix is to run the same cache-saving job on pushes to the default branch:

on:
  push:
    branches: [main]
  pull_request:
Enter fullscreen mode Exit fullscreen mode

A related trap: pull requests from forks run with a restricted token, and the cache they write is scoped to the PR merge ref, so don't expect fork PRs to warm anything for others.

Takeaway: if main never runs the cache step, no PR can ever get a warm start from it.

Why does the cache never update when my lockfile changes... or doesn't?

Caches are immutable. Once a key is saved, it cannot be overwritten. This bites in two opposite ways.

Key too static. A key like deps-${{ runner.os }} is saved once, then every later run restores it (an exact hit) and the post-step refuses to save new content. Your cache slowly goes stale and installs get slower, while the log says "Cache hit".

Key too volatile. A key containing ${{ github.sha }} is unique on every commit, so every run is a miss on the primary key and writes a fresh multi-hundred-MB entry. That floods the repository's cache quota and evicts the useful entries.

The standard pattern is a key derived from the file that determines the content, with a prefix fallback:

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-
Enter fullscreen mode Exit fullscreen mode

When the lockfile changes, the primary key misses, the prefix in restore-keys restores the most recent older cache, npm ci fills in the difference, and the post-step saves a new entry under the new key.

Takeaway: hash the lockfile, not the commit, and let restore-keys carry you across lockfile changes.

How do restore-keys and cache-hit actually behave?

Two details trip people up.

First, restore-keys are tried in order, and each is treated as a prefix. The first prefix that matches picks the most recently created cache among the matches. Put the most specific prefix first.

Second, the cache-hit output is 'true' only for an exact match on the primary key. A restore via restore-keys leaves it 'false' even though files were restored. If you gate npm ci on cache-hit != 'true' you will still run installs after a partial restore, which is usually what you want. If you gate it the other way, you will skip installs after a partial restore and ship a stale node_modules. Gating on cache-hit for something like node_modules is risky for exactly this reason; caching the package download directory (~/.npm) and always running npm ci is the safer shape.

Situation Primary key restore-keys Result
Lockfile unchanged, same branch lineage exact match n/a cache-hit: true, no save
Lockfile changed miss prefix match on older cache cache-hit: false, files restored, new entry saved
First PR in a repo, main never cached miss no match cold install, entry saved to PR ref only
Key includes github.sha always miss prefix match works, but one new entry per run
Same key already exists exact match n/a "Unable to reserve cache" warning on save

Takeaway: cache-hit means "exact key", not "something was restored", so don't use it to skip installs.

What about eviction and size limits?

As of late 2026, GitHub's documented defaults are: caches not accessed for 7 days are removed, and the repository total is capped (10 GB by default); when you exceed it, the oldest caches by last access are evicted first. Check the current numbers in the GitHub docs, since these have been adjustable. The practical effect: if you generate one big cache per PR, they can push out the default-branch cache that every PR depended on, and the next PR goes cold for no visible reason.

You can inspect what exists with the GitHub CLI:

gh cache list --limit 50
gh cache list --sort size_in_bytes --order desc
Enter fullscreen mode Exit fullscreen mode

If the list is full of refs/pull/*/merge entries and the refs/heads/main entry is missing or old, you have found your problem.

To stop PRs from writing caches at all while still reading main's, split restore and save:

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

- run: npm ci

- uses: actions/cache/save@v4
  if: github.ref == 'refs/heads/main'
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
Enter fullscreen mode Exit fullscreen mode

Only main saves, so PR caches never crowd out the shared one. The tradeoff: a PR that changes dependencies pays the full install every time until it merges. For most teams that is the right trade.

Takeaway: one writer (the default branch) and many readers (PRs) is the cache topology that stays warm.

When is a built-in cache not enough?

The built-in cache is fine for package downloads. It is a poor fit for Docker layers (large, and the 10 GB quota fills fast) and for build outputs that are expensive to restore compared with rebuilding. For Docker, BuildKit's registry or GitHub Actions cache exporter is the usual route. If you would rather not manage cache quotas at all, a hosted runner service with persistent disks, such as Depot or Blacksmith, is the kind of product that takes this problem off your plate, at the cost of another vendor and bill to evaluate. I would only reach for that after measuring that cache restore time actually dominates your pipeline.

FAQ

Why does GitHub Actions say "Cache not found" when the cache exists?
Caches are scoped by branch. A PR can read its own ref, its base branch, and the default branch, but not caches saved by other branches or PRs.

Can I overwrite an existing GitHub Actions cache?
No. Caches are immutable per key. Change the key (for example by hashing your lockfile) or delete the old entry with gh cache delete before saving again.

Does cache-hit be true when restore-keys matched?
No. cache-hit is 'true' only when the primary key matched exactly; a restore-keys match restores files but reports 'false'.

Bottom line

If your cache never hits on PRs, check three things in order: does main run and save the cache, is your key derived from the lockfile rather than a static string or a commit SHA, and do your restore-keys prefixes match the primary key's prefix. For most small teams, a single saver on the default branch plus restore-only PRs is the simplest setup that stays fast. Reach for external cache services only once you have measured that restore time, not install logic, is the bottleneck.

Related reading

Top comments (0)