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.

How a Cache Lookup Falls Back The lookup tries the exact key first and misses. It then tries a prefix scoped to the operating system and Node version, which matches an entry from yesterday. A more general prefix scoped only to the operating system is shown as a possible but unsafe fallback because it would match a cache built with a different Node version. 1 Β· exact key npm-linux-node20-9f3c2ab7e1 MISS β€” the lockfile changed this morning 2 Β· prefix, same OS + runtime npm-linux-node20- HIT β€” yesterday's entry, 97% reusable install reconciles the difference in ~14s 3 Β· prefix, OS only β€” do not add this npm-linux- would match a node18 cache native modules built for the wrong ABI Each fallback must preserve every property the cached content depends on. Stop the list where that stops being true. "Newest matching entry wins" β€” so a stale but broadly-matching prefix will keep winning until an exact entry is saved.

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.

Why the Save Step Must Be Explicit Three days of runs. With the combined cache action, day one restores a partial hit and skips the save, so days two and three keep restoring the same day-zero entry. With split restore and save, day one saves a fresh exact entry, so day two and three are exact hits. COMBINED ACTION β€” save skipped whenever any key matched day 1 Β· partial hit (entry from day 0) no save day 2 Β· partial hit (entry from day 0) no save day 3 Β· partial hit (entry from day 0) drifting further every day SPLIT restore + save β€” converges after one run day 1 Β· partial hit saves an exact entry day 2 Β· exact hit install ~6s day 3 Β· exact hit install ~6s The combined action is not broken β€” it is optimised for caches that never change. Dependency caches change constantly.

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}'
done

The 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”.

Why a Combined Hit Rate Hides the Problem Both repositories report a combined cache hit rate of ninety-five per cent. Broken down, the healthy one is eighty per cent exact and fifteen per cent partial, while the unhealthy one is five per cent exact and ninety per cent partial, indicating an unstable component in the cache key. BOTH REPORT "95% CACHE HIT RATE" healthy exact 80% partial unstable key partial 90% β€” downloads a near-miss, then reconciles, every single run miss Same headline number, opposite situations. Report exact and partial separately or the second one stays invisible. Fix the key first; tune fallbacks second.

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.

← Back to Securing and Invalidating Build Caches