skip to content

Caching & Matrix Builds

Making runs fast and broad: cache key and restore-key strategy for dependencies, and matrix builds with include/exclude, fail-fast and max-parallel, including matrices generated dynamically from JSON. Interviewers ask because a cache key that never invalidates and one that never hits are both common and both expensive.

part ofGitHub Actionsoverview, primer and where to startread it →
on this pageshow

questions

6

In GitHub Actions, how do actions/cache's key and restore-keys differ?

level: middleimportance: must knowfreq 72%

answer

  1. one is exact, the others are prefixes
  2. only one of them ever names a saved entry
  3. the output reports exact matches only
  4. entries cannot be overwritten in place
  5. most specific fallback first

basics

~20 s

key is the exact identifier looked up first and the name any new cache is saved under. restore-keys is an ordered list of prefixes tried only when key misses, restoring the newest partial match so the job starts from a warm but stale cache.

solid answer

~50 s

`actions/cache` first looks for an entry whose key equals `key` exactly. On a hit it restores the files and sets the step output `cache-hit` to `'true'`, and it will not save again at the end of the job. On a miss it walks `restore-keys` in order, treating each as a prefix and restoring the most recent entry that matches; `cache-hit` stays `'false'`, and at the end of a successful job the action saves the current contents under the exact `key`. That asymmetry is the whole design: the key contains a hash of the lockfile so it changes whenever dependencies change, while the restore-keys are a stable prefix that still gets you yesterday's dependency set to build on. Cache entries are immutable, so a key that already exists is never overwritten — the key must move when the content should.

code

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

# still run the install: a partial restore only warms the download cache
- run: npm ci

go deeper

for a junior

Know that key identifies the cache exactly and restore-keys are fallback prefixes, and that a lockfile hash belongs in the key so it changes when dependencies change.

for a middle

Walk the lookup order aloud — exact key, then each prefix newest-first — and explain that cache-hit reports only exact matches while a partial restore still saves under the new key at the end.

for a senior

Bring in immutability, branch scoping, and eviction: caches are best-effort, keys must move to refresh, and installs should still run so a partial restore cannot produce a wrong dependency tree.

for a principal

Own the strategy across repositories: what is cached and what is rebuilt, who seeds the default-branch cache, how the repository's cache budget is spent, and how you measure whether caching is actually paying for itself.

## The lookup, step by step A typical dependency cache looks like this: - uses: actions/cache@v4 id: npm-cache with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-npm- When the step runs: 1. **Exact match on `key`.** If an entry with precisely that key is visible to this run, its archive is extracted to `path`, and the step sets `cache-hit` to `'true'`. Because the entry already holds what this key describes, the action does **not** save anything in its post step. 2. **No exact match.** Each entry in `restore-keys` is tried in order as a **prefix**. The most recently created entry whose key starts with that prefix is restored. Files land on disk, but `cache-hit` is `'false'` — it reports exact hits only. 3. **Nothing matched.** Nothing is restored; the job installs from scratch. In cases 2 and 3, if the job succeeds, the action's post step archives `path` and saves it under the exact `key`. ## Why the two-part design The key is a content fingerprint. `hashFiles('**/package-lock.json')` returns a SHA-256 over the matched files, so the key changes exactly when the dependency set changes — correctness. The restore-keys are a stable prefix, so a lockfile bump still restores the previous cache and the package manager only downloads what actually changed — speed. Without restore-keys, every lockfile edit means a cold install; without a hashed key, the cache would never be refreshed at all. Order matters and should run from most to least specific: restore-keys: | ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} ${{ runner.os }}-npm- Include `runner.os` (or the matrix's OS value) in the prefix. Caches restored across operating systems are usually useless and sometimes actively broken, since native modules and paths differ. ## `cache-hit` and conditional steps `cache-hit` is a string, so compare it as one: - if: steps.npm-cache.outputs.cache-hit != 'true' run: npm ci A frequent bug is skipping the install on any restore, including a partial one — that leaves a stale `node_modules` that does not match the lockfile. The safe pattern is to cache the package manager's download directory rather than the installed tree, and always run the install; with a warm cache it is fast and it reconciles the difference. ## Immutability Cache entries are write-once. If the key already exists, the save is a no-op (the log says the cache already exists with that key). You cannot refresh a cache in place — the key must change to produce a new entry. This is why a hardcoded key such as `npm-cache` yields a permanently frozen cache, and why keys are built from a hash of whatever determines the content. ## Scope and lifetime Caches are scoped to a branch. A run can restore entries created on its own ref, and — for a pull request — on the base branch, plus entries from the repository's default branch; it cannot read a sibling feature branch's caches. This isolation is a security property, not an oversight: it stops a branch from poisoning another branch's build inputs. Entries also expire, and a repository has a total cache size budget with least-recently-used eviction when it is exceeded, so a warm cache is a best-effort optimisation and never a correctness dependency. ## Splitting restore and save `actions/cache/restore` and `actions/cache/save` let you separate the two halves — for example, restoring early, and saving only on the default branch so pull-request runs consume the cache without filling the budget with short-lived entries. Useful inputs on the main action include `fail-on-cache-miss` and `lookup-only`. ## The one-sentence answer "`key` is the exact identity and the save name; `restore-keys` are fallback prefixes tried on a miss, giving a warm start without ever preventing the cache from being refreshed."

  • After a restore-keys partial match, does actions/cache save a new entry at the end of the job?
    Yes. A partial restore leaves `cache-hit` as `'false'`, and the post step archives the path and saves it under the exact `key` if the job succeeded. That is how a lockfile bump refreshes the cache: the previous entry warms the install, and the result is stored under the new hashed key.
  • Why include runner.os in the cache key and restore-key prefix?
    Because a cache saved on Linux is at best useless and at worst broken on macOS or Windows — different paths, different native binaries. Without the OS in the prefix, a matrix job would happily restore another platform's archive and then fail in a way that looks like a build bug rather than a cache bug.
  • Why is caching the package manager's download directory usually safer than caching node_modules?
    The download directory is a content-addressed store, so a stale or partial restore is reconciled by simply running the install. An installed tree is state: if it does not exactly match the lockfile and the job skips installation on a partial hit, the build silently runs against the wrong dependency versions.

saying these in an interview costs you the question

  • Thinks restore-keys must match a key exactly
  • Believes cache-hit is true on a partial restore
  • Uses a fixed key and wonders why deps never update
  • Skips install on any restore, including partial
  • Expects a cache entry to be overwritten in place

context

open as a page

In a GitHub Actions strategy matrix, what do include and exclude actually do?

level: middleimportance: must knowfreq 68%

basics

~20 s

The matrix's array variables expand to the cross product of every combination, one job each. exclude removes combinations from that product, and include then adds extra values to matching combinations or appends whole new ones. Exclude is processed first, so include can add a combination back.

open as a page

In a GitHub Actions matrix build, what does setting fail-fast: false change?

level: middleimportance: should knowfreq 54%

basics

~20 s

By default a failing matrix job cancels every other in-progress and queued job in that matrix. fail-fast: false lets all legs run to completion, so one pull request shows every platform or version that is broken instead of only the first one to fail.

open as a page

How do you build a GitHub Actions matrix at runtime from a previous job's output?

level: seniorimportance: should knowfreq 46%

basics

~20 s

A setup job computes a JSON array, writes it to GITHUB_OUTPUT, and declares it as a job output. The downstream job depends on it with needs and sets strategy.matrix to fromJSON of that output, which parses the string into the matrix structure.

open as a page

A GitHub Actions cache reports cache-hit true but builds use stale dependencies. Why?

level: seniorimportance: should knowfreq 48%

basics

~20 s

The actions/cache key contains nothing that changes when dependencies change, so it matches forever. Cache entries are immutable, so the first archive saved under that key is served indefinitely and no new one can replace it.

open as a page

Your GitHub Actions matrix has grown to 60 jobs per pull request. How do you decide what to keep?

level: principalimportance: nice to knowfreq 34%

basics

~20 s

Rank each dimension by the failures it has actually caught, keep on every pull request only the legs that catch defects a merge would ship, and move the rest to a scheduled or pre-release run. Then attack the per-leg cost with caching and change detection.

open as a page