Skip to content
Latchkey

actions/cache: Keys, Limits and Scoping Rules

cache-hit is only true on an exact key match. A restore-keys prefix match still restores files while reporting cache-hit: false, which is how a stale cache gets mistaken for no cache at all.

Caching failures split into three shapes that need different fixes: the cache never saved, the key never matches on restore, or a prefix match restored a stale entry that is now poisoning the build. The step outputs tell you which one you have, provided you read them correctly.

The rules below are the ones that produce surprises. The cap and the eviction policy in particular fail silently, showing up as builds gradually getting slower with no error to investigate.

Keys and restore-keys

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

- run: |
    echo "exact hit: ${{ steps.cache.outputs.cache-hit }}"
    echo "key used:  ${{ steps.cache.outputs.cache-matched-key }}"

Limits and eviction

RuleValueFailure mode
Repository cache size10 GB totalSilent LRU eviction; caches stop persisting
Unused entry eviction7 daysInfrequent workflows never have a warm cache
Branch scopeOwn branch plus baseA sibling feature branch cannot use it
ImmutabilityA key is written onceRe-saving the same key silently no-ops

Branch scoping is the confusing one

  • A cache written on main is readable by every branch based on it.
  • A cache written on feature-a is NOT readable from feature-b.
  • This is why a cache appears to work on the default branch and never hit on pull requests.
  • Warm the cache on the default branch on a schedule if pull request jobs need it.

Key design that works

.github/workflows/ci.yml
# include everything that changes the cached content
key: ${{ runner.os }}-${{ matrix.node }}-npm-${{ hashFiles('**/package-lock.json') }}

# BAD: no lockfile hash, so a stale tree is restored forever
key: ${{ runner.os }}-npm

# BAD: too specific, never hits again
key: ${{ github.sha }}

Save even when the job fails

.github/workflows/ci.yml
# split restore and save so a failing test run still saves deps
- uses: actions/cache/restore@v4
  id: restore
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}

- run: npm ci
- run: npm test

- uses: actions/cache/save@v4
  if: always() && steps.restore.outputs.cache-hit != 'true'
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}

Diagnose it: was the cache hit, and was it the right one?

Cache bugs split into three shapes and they need different fixes: the cache never saved, it saved but the key never matches on restore, or it restored a stale entry through a restore-keys prefix and is now poisoning the build. The step output tells you which one you have.

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

- name: What happened
  run: |
    echo "exact hit: ${{ steps.cache.outputs.cache-hit }}"
    echo "key used:  ${{ steps.cache.outputs.cache-matched-key }}"

Cache limits that produce confusing failures

  • Repository cache is capped at 10 GB. Past that, GitHub evicts least-recently-used entries, so a large cache can silently stop persisting.
  • Caches are scoped by branch. A cache written on a feature branch is not visible to another feature branch, only to its base and its own descendants.
  • An entry not read for 7 days is evicted, so a rarely-run workflow effectively never has a warm cache.
  • Restoring a cache built for a different tool version is worse than a cold start, because you get a corrupted tree instead of a clean install. Always include the tool version in the key.

Frequently asked questions

Why is cache-hit false when the cache restored?
cache-hit is only true for an exact key match. A restore-keys prefix match restores files while reporting cache-hit: false, which is expected behaviour and frequently mistaken for a cache miss.
What is the GitHub Actions cache size limit?
10 GB per repository across all caches, with least-recently-used eviction past it. Entries unused for 7 days are also evicted. Both happen silently, so the symptom is builds getting slower rather than an error.
Why does my cache never hit on pull requests?
Caches are branch-scoped. A branch can read caches from its own ref and its base, but not from sibling branches. A cache written only on a feature branch is invisible to other pull requests.
Why is my cache not saving?
The combined actions/cache action only saves on job success, so a failing job never warms the cache. Cache keys are also immutable: re-saving an existing key silently does nothing. Split into restore and save with if: always() to save regardless of outcome.

Related guides

References

Run this faster and cheaper on Latchkey managed runners - self-healing included. Start free → 30-day trial · No credit card