← Back to the blog

GitHub Actions Cache: Speed Up Builds Without Stale Cache

By Squidly Team · · 7 min read

  • #github-actions
  • #ci-cd
  • #cache
  • #performance

TL;DR

A GitHub Actions cache is restored when its key matches exactly, or, failing that, when a restore-keys prefix matches. It is saved only when the job succeeds, and a saved cache can never be changed. To speed up builds without serving stale data, put the files that decide your dependencies (such as a lockfile) in the key, use restore-keys only as a fallback, and check the cache-hit output before you trust the result.

How the cache key works

The key is the identity of a cache. Two runs that compute the same key share the cache. GitHub looks for it in this order:

  1. an exact match on key, in the branch of the workflow run;
  2. a prefix match on key, in the same branch;
  3. each entry of restore-keys, in the order you wrote them;
  4. the same steps again on the default branch.

If no exact match is found, the action restores the most recently created cache that matches a restore key. It then creates a new cache when the job succeeds.

Here is a typical Node.js setup:

- uses: actions/checkout@v4

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

- name: Install dependencies
  run: npm ci

The key changes whenever package-lock.json changes, so a new dependency set gets a fresh cache. The restore-keys entry lets a run that missed the exact key start from the closest previous cache instead of from nothing.

Pitfall 1: a key that does not change when it should

The most common cause of stale results is a key built from something that never changes:

# Risky: the key never changes, so the first cache is reused forever
key: npm-cache
# Better: the key follows the file that defines the dependencies
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}

Remember that a cache cannot be updated in place. If you want new content, you must create a new key. Think about what the cached directory depends on:

  • Dependencies: the lockfile, the Node, Python or Java version, and the operating system.
  • Compiled output: the source files that produce it, not only the lockfile.
  • Tooling: the tool version, if it is part of the cached data.

Include every input that changes the content in the key, and keep the list short enough that cache hits remain frequent.

Pitfall 2: restore-keys that are too broad

restore-keys is a fallback, and a fallback can restore data that is no longer correct. A prefix such as npm- matches every npm cache of the repository, and GitHub restores the most recent one. For package caches this is usually acceptable, because the install step then fixes the difference. For compiled output or generated files, it can silently keep old artifacts.

Use the most specific restore key that still gives a useful head start:

key: ${{ runner.os }}-build-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**') }}
restore-keys: |
  ${{ runner.os }}-build-${{ hashFiles('package-lock.json') }}-
  ${{ runner.os }}-build-

The exact key combines two hashes: one for the lockfile and one for the source files. The first restore-key uses only the lockfile hash, so it can find a cache with the same dependencies but older sources. Because it is a prefix of the key, it matches. The last one matches any build cache for the operating system. The action tries restore-keys in the order you write them, so keep the most specific key first and the broadest one last.

Pitfall 3: trusting cache-hit when a restore key was used

The cache-hit output is true only for an exact match on key. When a restore-keys entry restores an older cache, the files are restored, but cache-hit is false. Conditions written as if: steps.npm-cache.outputs.cache-hit != 'true' therefore run even when a useful cache was restored.

This is usually what you want for an install step, because the install still runs and reconciles the restored files. It is a problem if you skip work on the assumption that an exact hit means the output is up to date. Check the output before you skip anything:

- name: Show what was restored
  run: |
    echo "Exact match: ${{ steps.npm-cache.outputs.cache-hit }}"
    du -sh ~/.npm || true

Pitfall 4: branch scope and pull requests

Caches are isolated by branch, and they are not shared with sibling branches:

  • A run can restore caches from its own branch and from the default branch. For a pull request, it also reads caches from the base branch.
  • A cache created by a pull request run is scoped to the pull request's merge ref. Other branches and other pull requests cannot restore it.
  • A cache created on a feature branch is not visible to the default branch.

This is good for isolation, but it explains many "the cache never hits" reports. If your default branch never saves a cache, feature branches have nothing to restore from except what they saved themselves. A typical fix is to save the cache from a push to the default branch, so every new branch starts from it.

Pitfall 5: caches that expire silently

GitHub removes any cache entry that has not been accessed for more than 7 days. Each repository also has a storage limit, 10 GB by default. When the limit is reached, GitHub deletes caches in order of last access date, starting with the least recently accessed. The limit can be raised in the repository, organization or enterprise settings. A weekly job that does not run during a quiet period can therefore lose its cache, and the next build becomes slow without any error.

To see what is stored, open the repository's Actions tab and go to the caches management page, described in GitHub's "Managing caches" documentation. Use the settings page only to change the storage limit. If you see cache misses after a quiet period, check this page first.

Pitfall 6: caches in workflows triggered by outside contributors

Caches are a possible attack path. Anyone who can read a cache can read its files, and a cache restored into a privileged run can run attacker-controlled content. GitHub applies a safer default to low-trust triggers such as pull_request_target, issue_comment and workflow_run: they can restore caches from the default branch but cannot write to it.

Keep these rules in mind:

  • Do not store tokens, credentials or other secrets in a cached path.
  • Write to the cache only from trusted triggers, typically push to the default branch.
  • In low-trust workflows, use actions/cache/restore when the job should only read, or declare cache-mode: read to make read-only access explicit.
  • If you declare cache-mode: write or write-only on a low-trust workflow, you remove the read-only default that protects against cache poisoning.
  • When a read-only run tries to save a cache, the save fails with a warning, but the job does not fail. This is a common reason why the cache never updates, so look for that warning in the logs.

A checklist for reliable caches

Check Why it matters
The key includes the lockfile or the files that define the content A change in dependencies creates a new cache
The restore-keys fallback is specific Old data is less likely to be restored into a build
cache-hit is read correctly A restored cache is not mistaken for an exact match
The default branch saves a cache New branches have something to restore
The cache is used regularly Caches unused for 7 days are removed
No secrets are written to the cache Caches can be read by people who can open pull requests

When a cache hides a real problem

A cache is an optimization. If a build only passes with a restored cache, the workflow has a hidden dependency. Run the job once with an empty cache, for example by changing the key temporarily, before you assume the cache is safe to rely on.

When a workflow becomes flaky, look at its logs first. The guide on how to debug a failing GitHub Actions workflow explains how to separate configuration errors from code errors. If the failures concern several repositories at once, monitoring GitHub Actions across multiple repositories covers how to keep the overview. For the trade-offs between GitHub's own notifications and a dedicated tool, see GitHub Actions: native notifications or Squidly. To be alerted when a workflow starts failing after a cache change, see GitHub Actions workflow notifications.

Conclusion

A good cache setup has three parts: a key that changes with the inputs that matter, a fallback that stays specific, and a check on what was actually restored. Start with the lockfile in the key, measure the time saved, and add restore-keys only where a partial restore is safe. Keep an eye on branch scope and the 7-day expiry, and never put secrets in the cache.