Using Restore Keys for Safe Cache Fallbacks
Someone adds one dependency, the lockfile hash changes, the cache key misses, and CI spends two minutes downloading a dependency tree that is 99% identical to the one it had yesterday. Restore keys fix that β but a fallback list written carelessly restores a cache built for a different platform or runtime, which is slower and wrong.
When to use this pattern
- Any lockfile change currently costs a full cold install.
- Your cache hit rate is high on repeat runs but collapses on dependency-update pull requests.
- You have caches keyed by content hash and no graceful degradation between them.
Prerequisites
How prefix matching resolves
The primary key is matched exactly. If it misses, each entry in restore-keys is tried in order as a prefix, and the newest entry whose key starts with that prefix wins. The order of the list is therefore the order of your preferences, and the last entry is the most dangerous one.
Complete working example
# .github/workflows/ci.yml β exact key with two safe fallbacks.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
# Split restore and save so a partial hit still writes a fresh exact entry.
- uses: actions/cache/restore@v4
id: cache
with:
path: |
~/.npm
node_modules
# Everything that changes what the cache contains is in the key.
key: npm-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-node20-
# NOT "npm-${{ runner.os }}-" β that would cross the runtime boundary.
- run: npm ci --prefer-offline --ignore-scripts
# Save unconditionally on an exact miss, including after a partial restore,
# so tomorrow's run has an exact key to hit.
- uses: actions/cache/save@v4
if: steps.cache.outputs.cache-hit != 'true'
with:
path: |
~/.npm
node_modules
key: npm-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}For a build cache with more dimensions, order the fallbacks from most to least specific:
- uses: actions/cache/restore@v4
with:
path: .next/cache
key: next-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
restore-keys: |
next-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}-
next-${{ runner.os }}-node20-The first fallback reuses the incremental build cache from a previous commit with identical dependencies; the second accepts a dependency change but keeps the runtime fixed. Both are safe because a Next.js build cache is validated internally β stale entries are discarded rather than trusted.
Step-by-step walkthrough
The primary key must be exact and complete. Every input that changes what the cache holds belongs in it: the runner OS, the runtime version, and the lockfile hash at minimum. If an exact hit can ever be wrong, the fallbacks are not your problem β the key is.
Fallbacks are prefixes, so key order matters at design time. npm-linux-node20-<hash> can degrade to npm-linux-node20- and then to npm-linux-, but never to βany Node 20 cache on any OSβ, because the OS comes first in the string. Put the properties you must never cross earliest in the key.
Stop the list at the last safe boundary. The rule of thumb: a fallback is safe when the install or build step can reconcile the difference. A tree of JavaScript packages reconciles fine. A compiled native module built against Node 18βs ABI does not β it loads and then segfaults, or fails with Module did not self-register. That boundary is where the list ends.
Split restore and save. With the combined actions/cache action, a restore-key hit counts as βa cache was foundβ and the post-run save is skipped. The result is a cache that never converges: every run partially restores the same increasingly stale entry. Using cache/restore plus a conditional cache/save is the fix, and it is the single most common bug in this area.
A partial hit still runs the install. Restore keys do not skip work; they make the work cheaper. npm ci --prefer-offline against a warm ~/.npm resolves from disk and downloads only what changed β typically 10β20 seconds instead of two minutes.
Verification
# 1. Which key actually matched β this is logged, and it is the ground truth.
gh run view --log --job build | grep -iE 'cache (restored from key|hit|not found)'Expected on a dependency-update pull request:
Cache not found for input keys: npm-Linux-node20-9f3c2ab7e1
Cache restored from key: npm-Linux-node20-4b81ce07fa# 2. The save happened, so tomorrow gets an exact hit.
gh run view --log --job build | grep -i 'cache saved with key'
# 3. Measure the effect on install time across recent runs.
gh run list --limit 30 --json databaseId --jq '.[].databaseId' | while read -r id; do
gh run view "$id" --log --job build 2>/dev/null \
| awk '/Run npm ci/{t=$1} /Post Run npm ci/{print t, $1}'
doneThe second command is the one to check first when hit rates look wrong. If Cache saved with key never appears, the save step is being skipped and the fallback will keep returning older and older entries.
Where fallbacks stop being an optimisation
There is a point at which restore keys hide a problem rather than solving one. If the exact key misses on almost every run, the fallback is doing the work every time and the real issue is a key input that changes constantly β a timestamp, a commit SHA in a dependency-cache key, or an environment variable that differs per run.
The diagnostic is the ratio of exact hits to partial hits. A healthy repository sits around 70β85% exact, with partial hits concentrated on dependency-update pull requests. A repository at 5% exact and 90% partial is not benefiting from caching so much as repeatedly paying to download a slightly wrong cache and then fix it. In that state, removing a single unstable component from the key usually recovers more time than any amount of fallback tuning β and the fix is invisible until you separate the two hit types in your metrics rather than reporting one combined βcache hit rateβ.
Common pitfalls
A fallback that crosses a compiled-artifact boundary. Restoring a Node 18 node_modules into a Node 20 job produces native modules that fail at require time, often deep in an unrelated test. Keep the runtime in the non-fallback part of the key.
Using the combined cache action and wondering why it never updates. Split into cache/restore and cache/save, as above.
Adding fallbacks to a build-output cache that cannot self-validate. Dependency stores and framework build caches validate their own contents; a naive directory of compiled output does not, and a partial restore leaves stale files the build never overwrites. Scope those caches exactly, per cache-key strategies for deterministic CI builds.
Related
- Securing & Invalidating Build Caches β the parent guide on cache scoping and invalidation.
- Cache-Key Strategies for Deterministic CI Builds β designing the exact key the fallbacks degrade from.
- Best Practices for Caching npm vs Yarn vs pnpm in CI β which paths are worth caching per package manager.
- Build Optimization & Caching Strategies β the section overview.
β Back to Securing and Invalidating Build Caches