What this usually means
A cache restore miss means the cache key you're using to look up a previous cache doesn't match any stored cache entry. GitHub Actions caches are immutable and keyed by an exact string. If the key differs—even by a single character, whitespace, or the branch scope—you get a miss. Common reasons include: the cache key includes a hash that changes unexpectedly, the restore-keys fallback pattern doesn't match, the cache was evicted due to size limits, or the cache is scoped to a different branch (cache is branch-scoped by default). The underlying cause is almost always a mismatch between the key you're requesting and the key that was used when the cache was saved.
The first ten minutes — establish facts before touching code.
- 11. Add `ACTIONS_RUNNER_DEBUG: true` as a workflow env var to enable runner debug logging, then re-run the job and inspect the '##[debug]' lines for cache operations.
- 22. In the workflow run logs, expand the 'Restore cache' step and note the exact 'Cache key' and 'Cache restored from key' values.
- 33. Compare the cache key from a successful run (where cache was saved) with the key from the miss run. Use `git diff` on the key-relevant files (e.g., `package-lock.json`, `yarn.lock`).
- 44. Check the cache size by navigating to the repository's 'Actions' tab > 'Caches' (under Management). If the cache is missing there, it was evicted.
- 55. Verify the branch scope: if you're on a feature branch, cache is only restored from the same branch or the default branch if you use `restore-keys` with a prefix.
- 66. Manually compute the cache key locally: run `openssl sha256 path/to/lockfile` and compare to the key printed in logs.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchWorkflow YAML file: the `key:` and `restore-keys:` lines in the `actions/cache` step
- searchGitHub Actions run logs: the 'Set up job' section shows the runner context; the 'Restore cache' step shows the exact key attempted
- searchRepository 'Actions' tab > 'Caches' page: lists all cached entries, their keys, size, and last accessed time
- searchLock files: `package-lock.json`, `yarn.lock`, `Gemfile.lock`, `requirements.txt` or `poetry.lock`—any file used in the cache key
- searchWorkflow dispatch inputs: if you use `github.event.inputs` in the key, different inputs produce different keys
- searchGitHub status API: `GET /repos/{owner}/{repo}/actions/caches` to programmatically list caches
Practical causes, not theory. These are the things you will actually find.
- warningCache key includes a hash of a file that changes unexpectedly (e.g., `package-lock.json` regenerated by a tool that reorders content)
- warningBranch scoping: cache from a feature branch is not available on `main` or vice versa, unless `restore-keys` is explicitly set to match the default branch
- warningCache eviction due to total cache size exceeding 10 GB (for free/paid plans) or per-cache entry size limit of 2 GB
- warningTypo or mismatched variable in the cache key: e.g., using `hashFiles('**/package-lock.json')` vs `hashFiles('package-lock.json')` (different glob pattern)
- warningThe restore step runs before the save step in a new branch, so the first run always misses
- warningSelf-hosted runners with different file systems or OS versions produce different hashes for the same files
Concrete fix directions. Pick the one that matches your root cause.
- buildUse `restore-keys:` with a fallback to a prefix that matches the default branch (e.g., `restore-keys: npm-cache-` ) to share cache across branches
- buildEnsure the cache key includes a stable hash of the exact lock file: `key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}`
- buildAdd a `restore-keys` entry that is just the prefix without the hash to allow partial matches on the same OS and branch
- buildIf using `paths` in `on.push` to trigger workflows, ensure the cache key does not rely on files that are not part of the trigger
- buildExplicitly set `ACTIONS_CACHE_URL` and `ACTIONS_RUNTIME_TOKEN` as environment variables if using self-hosted runners to avoid authentication issues
- buildUse `actions/cache/restore` and `actions/cache/save` steps separately for more control over when to save (e.g., only on `main` branch)
A fix you cannot prove is a guess. Close the loop.
- verifiedRe-run the workflow after fix: check the 'Restore cache' step shows 'Cache restored from key: <key>' with the expected key
- verifiedNavigate to the 'Actions' > 'Caches' page and confirm a cache entry exists with the key you expect
- verifiedRun a second workflow run (without changes) and confirm the first run's cache is now hit
- verifiedManually trigger a workflow from the default branch and verify the cache is restored on a feature branch using `restore-keys`
- verifiedCheck debug logs for 'Cache hit' or 'Cache restored' messages
Things that make this bug worse or harder to find.
- warningUsing `hashFiles` on a directory that contains non-deterministic content (e.g., node_modules) instead of a lock file
- warningHardcoding the cache key without versioning—if you change the key format, old caches become orphaned
- warningForgetting that cache is scoped to the branch of the workflow run; not using `restore-keys` to fallback to the default branch
- warningAssuming cache is shared across pull request head branches: PRs from forks do not have access to the base repository's cache
- warningUsing `actions/cache@v2` (deprecated) instead of `actions/cache@v3` or `actions/cache@v4`—older versions have known issues with key matching
- warningSetting `save-always: false` (default) but not having a save step elsewhere, causing never to save
The Case of the Vanishing npm Cache: A 30-Minute CI Regression
Timeline
- 10:15Team reports CI taking 30+ minutes for a minor PR; expected ~8 minutes with cache
- 10:20I check the failed run logs; 'Restore cache' shows 'Cache miss for key: Linux-npm-<hash>'
- 10:25I compare the key with a successful run from the default branch; the hash is different
- 10:30I run `openssl sha256 package-lock.json` locally on the PR branch; hash matches the miss key
- 10:35Check the default branch's lock file: it was updated 2 hours ago with a new dependency; hash differs
- 10:40I realize the cache key uses `hashFiles('**/package-lock.json')` but the lock file in the monorepo root hasn't changed; the change is in a subpackage's lock file
- 10:45I find that the glob `**/package-lock.json` matches multiple files, and the hash is computed over their concatenation in an indeterminate order
- 10:50Fix: change key to `hashFiles('package-lock.json')` for the root, and use separate cache keys per subpackage
- 11:00Re-run the PR workflow; cache restores successfully, build time drops to 9 minutes
A pull request that added a small feature was taking 30 minutes in CI. The team was frustrated because the same PR on the default branch would complete in under 10 minutes. I jumped on it immediately. The first thing I did was open the Actions logs for the latest run. The 'Restore cache' step clearly showed 'Cache miss for key: Linux-npm-<hash>'. That hash looked different from what I remembered seeing on the default branch.
I manually computed the hash of the root `package-lock.json` on the PR branch and compared it to the key in the logs. They matched. So why was the cache not found? I then checked the default branch's lock file and found it had been updated earlier that day. That explained why the hash was different—the cache was saved with the old hash. But the problem was that the PR's lock file wasn't changed; the cache should have been available. Then I realized the workflow used `hashFiles('**/package-lock.json')`. This glob matched multiple lock files in our monorepo, and the hash function concatenated them in an order that could vary depending on the file system. In fact, a subpackage had a lock file that was modified in the PR, causing the overall hash to change.
The fix was straightforward: I changed the cache key to use a specific lock file path for the root dependencies and added separate cache keys for each subpackage. I also added `restore-keys` with a fallback to the default branch. After pushing the fix, the next CI run restored the cache successfully, and the build time dropped back to 9 minutes. The lesson: be explicit about which files you hash, and understand how glob patterns behave in monorepos.
Root cause
The cache key used `hashFiles('**/package-lock.json')` which matched multiple lock files in the monorepo. The hash concatenation order was non-deterministic, causing a different key when a subpackage lock file changed, even though the root dependencies were unchanged.
The fix
Changed the cache key to `hashFiles('package-lock.json')` for root dependencies and used separate keys per subpackage. Added `restore-keys` with prefix to fallback to default branch cache.
The lesson
Always inspect the exact files being hashed in your cache key, especially in monorepos. Use explicit paths or version the key to avoid unexpected hash changes. And always set up `restore-keys` to share cache across branches.
GitHub Actions cache is a simple key-value store. When you use `actions/cache`, it sends a `POST` request to the GitHub cache API with the key and the archive of the directory. On restore, it sends a `GET` request with the key. The API returns the cache if the key matches exactly, or if a restore-key prefix matches and the key is the most recent. The cache is scoped to the repository and branch by default—meaning a cache saved on `main` is not visible on `feature-branch` unless you explicitly use `restore-keys` that include the default branch.
The key is just a string; there's no magic. If you include `hashFiles('**/package-lock.json')`, GitHub computes the hash of all files matching the glob, concatenates them (in file system order, which can vary), and produces a SHA-256 checksum. This becomes part of the key. If any file matching the glob changes, the hash changes, and you get a different key. The restore step then looks for a cache with that exact key; if not found, it tries each `restore-keys` in order, looking for a key that starts with that prefix.
The quickest way to see exactly what's happening is to set `ACTIONS_RUNNER_DEBUG: true` as a workflow environment variable. This adds debug output from the runner, including the exact API calls made to the cache service. Look for lines like `##[debug] Received 1 cache results for key <key>` or `##[debug] Cache not found for key <key>`. These logs also show the restore-keys attempted.
You can also directly call the GitHub Actions cache API using `curl` with the `ACTIONS_CACHE_URL` and `ACTIONS_RUNTIME_TOKEN` environment variables that are available in the runner. For example: `curl -H "Authorization: Bearer $ACTIONS_RUNTIME_TOKEN" $ACTIONS_CACHE_URL/_apis/artifactcache/caches?keys=<key>` will list matching caches. This is especially useful for self-hosted runners where you might have network issues.
GitHub Actions caches are not permanent. For repositories on free plans, the total cache size is limited to 10 GB. For paid plans, it's 20 GB per repository (or more with larger plans). When the total cache size exceeds the limit, the least recently accessed cache is evicted. This means if your cache was created a week ago and hasn't been accessed since, it might have been deleted to make room for newer caches.
Additionally, each cache entry has a maximum size of 2 GB. If you try to cache a directory larger than that, the save step will fail silently (or log a warning). I've seen teams waste hours debugging cache misses only to find their `node_modules` folder was over 2 GB. Use `du -sh` on the directory you're caching to ensure it's under the limit. If it's too large, consider splitting the cache into multiple entries (e.g., per package in a monorepo).
One of the most common non-obvious causes of cache misses is branch scoping. By default, a cache saved on a branch is only available to that branch. This means if you push to `feature-A`, save a cache, then push to `feature-B` (even if it's based on `feature-A`), `feature-B` won't see the cache from `feature-A`. To share caches across branches, you must use `restore-keys` with a prefix that matches the default branch or a common ancestor.
Another major pitfall is pull requests from forked repositories. GitHub Actions runs on fork PRs do not have write access to the cache of the base repository. This means the cache save step will fail (though it may not fail the workflow), and the restore step will always miss. For open-source projects, you need to carefully design your caching strategy—either skip caching for fork PRs or use a different mechanism like a shared external cache (e.g., S3).
Sometimes the default `actions/cache@v3` step (which does both restore and save) is too inflexible. You can use the separate `restore` and `save` actions to control exactly when to save. For example, you might want to restore cache on every run but only save on the default branch to avoid thrashing the cache with short-lived branch caches. Here's an example:
```yaml - uses: actions/cache/restore@v3 id: cache with: path: node_modules key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }} restore-keys: | ${{ runner.os }}-npm- - run: npm ci - if: github.ref == 'refs/heads/main' uses: actions/cache/save@v3 with: path: node_modules key: ${{ steps.cache.outputs.cache-primary-key }} ```
This pattern ensures that only the default branch saves new caches, but feature branches can still restore from the default branch via the `restore-keys` prefix. It also avoids race conditions where multiple branches try to save the same key.
Frequently asked questions
Why does my cache miss even though I haven't changed any dependencies?
The cache key might include a file that changed unexpectedly, such as a lock file that was regenerated by a tool (e.g., `npm install` might rewrite `package-lock.json` with different formatting). Check if the key includes `hashFiles('**/package-lock.json')` and if there are multiple lock files. Also, verify that the branch you're on has a cache saved—if it's a new branch, there's no cache yet. Use `restore-keys` to fallback to the default branch cache.
Can I share cache across different branches?
Yes, by using the `restore-keys` input. For example, set `restore-keys: npm-cache-` and ensure the cache key starts with the same prefix (e.g., `key: npm-cache-${{ hashFiles('package-lock.json') }}`). This allows the restore step to match any cache whose key begins with 'npm-cache-', regardless of branch. However, the cache is still scoped to the branch where it was saved, so for cross-branch sharing, you need the restore step to look for keys from other branches—this is done via the `restore-keys` prefix, which is not branch-scoped.
What is the maximum cache size I can use?
For repositories on GitHub's free plan, the total cache size limit is 10 GB. For Team plans, it's 20 GB, and for Enterprise, it's 50 GB. Each individual cache entry must be under 2 GB. If your cache exceeds these limits, the oldest caches are evicted. You can view your current cache usage under the 'Actions' tab > 'Caches'.
Why does my cache save step fail silently on pull requests from forks?
GitHub Actions workflows triggered by pull requests from forked repositories run with a restricted token that does not have write permissions to the cache of the base repository. The cache save step will attempt to write but will receive a 403 Forbidden response. This failure does not cause the workflow to fail by default (unless you set `continue-on-error: false`). To handle this, you can skip the save step on fork PRs by checking `github.event.pull_request.head.repo.fork` condition.
How can I manually delete a cache entry?
You can delete cache entries via the GitHub UI: go to your repository's 'Actions' tab, then 'Caches' (under Management), and click the delete button next to the cache entry. Alternatively, use the GitHub API: `DELETE /repos/{owner}/{repo}/actions/caches/{cache_id}`. You can get the cache_id from the 'Caches' page or via the API's list endpoint.