Cache Hit Rate
Cache hit rate is the share of cache lookups that return a usable entry. How to pick the numerator and the denominator, and how to count it in GitHub Actions.
Cache hit rate is the share of cache lookups that find a usable entry, counted over a window of runs and reported as a percentage. It is measured per key prefix or per workflow, because one repository holds several caches whose change rates have nothing to do with each other.
The number carries no information until the numerator and the denominator are stated. Two engineers reading the same week of runs on the same cache can report 50 percent and 80 percent and both be arithmetically correct.
Definition
A lookup is one request to a cache store for an entry under a named key. A hit is a lookup that returns an entry the job can use. The rate is hits divided by lookups over a stated window, which folds three separate choices into one figure.
The numerator. Caches that support prefix fallback return two different kinds of success. An exact match returns the entry the run asked for. A fallback match returns an older entry whose key is a prefix of the requested key, which covers part of the work and leaves the rest. Counting only exact matches gives the strict rate. Counting any returned bytes gives the restore rate, which runs higher on the same data.
The denominator. The set of lookups being divided into has three defensible definitions, and they diverge as soon as a workflow gets complicated.
| Denominator | What the rate then answers | Where it misleads |
|---|---|---|
| Every execution of the restore step | How often the store served a request | A matrix job that fans out to eight variants contributes eight lookups for one logical build |
| Every job that could use the cache | How often a job started warm | Jobs that restore two caches collapse into one data point |
| Every unique key requested in the window | How well the key design matches the change rate | A key requested 400 times and missed once looks the same as a key requested twice |
The window and the grouping. A rate over a seven day window on a repository that deploys twice a day mixes weekday and weekend traffic, and a rate over all caches in a repository averages a lockfile cache that changes monthly against a compiled artifact cache that changes hourly. Grouping by key prefix keeps caches with different change rates apart.
Two properties limit how far the number can be pushed. Some misses are correct: when the cached contents genuinely changed, a miss is the cache doing its job, and the ceiling for any cache is set by how often its inputs change. And a hit is not the same as time saved, because a fallback match can return a tree that still forces a full reinstall. A restore rate that climbs while job duration holds flat means the restored entries are covering less than they appear to.
Entries also vanish from underneath a stable key. GitHub removes cache entries that have not been accessed for seven days, and once a repository passes its cache storage limit it evicts least recently used entries to make room (GitHub caching documentation, checked on 2026-08-13). A rate that decays over a quarter with no change to the workflow file is usually cache eviction rather than key design.
In GitHub Actions the restore step reports its outcome through three outputs. cache-hit is true only on an exact match against the primary key, so a fallback restore sets it to false (GitHub dependency caching reference, checked on 2026-08-13). cache-matched-key holds the key that actually matched and is empty when nothing did, and cache-primary-key echoes the evaluated key (actions/cache README). Those three values are enough to compute either numerator.
Example
This workflow restores an npm download cache and writes the outcome of that lookup into the job summary, so every run leaves one line that can be counted later (job summaries reference, checked on 2026-08-13).
name: test
on:
push:
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Restore npm cache
id: npm-cache
uses: actions/cache/restore@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- name: Record cache outcome
env:
HIT: ${{ steps.npm-cache.outputs.cache-hit }}
MATCHED: ${{ steps.npm-cache.outputs.cache-matched-key }}
PRIMARY: ${{ steps.npm-cache.outputs.cache-primary-key }}
run: |
if [ "$HIT" = "true" ]; then
outcome=exact
elif [ -n "$MATCHED" ]; then
outcome=partial
else
outcome=miss
fi
echo "cache=npm outcome=$outcome primary=$PRIMARY matched=${MATCHED:-none}" \
>> "$GITHUB_STEP_SUMMARY"
- run: npm ci
- run: npm test
- name: Save npm cache
if: steps.npm-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: ~/.npm
key: ${{ steps.npm-cache.outputs.cache-primary-key }}The three-way branch is what makes the line countable. A single boolean would flatten fallback restores into the same bucket as complete misses, and those two outcomes call for different fixes.
Ten consecutive runs of that workflow, with lock file hash 9f2c1a as the starting state:
| Run | What changed | cache-hit | cache-matched-key | Outcome recorded |
|---|---|---|---|---|
| 1 | first run, store empty | false | empty | miss |
| 2 | nothing | true | npm-Linux-9f2c1a | exact |
| 3 | nothing | true | npm-Linux-9f2c1a | exact |
| 4 | one dependency added | false | npm-Linux- | partial |
| 5 | nothing | true | npm-Linux-4b77de | exact |
| 6 | nothing | true | npm-Linux-4b77de | exact |
| 7 | dependency bump | false | npm-Linux- | partial |
| 8 | nothing | true | npm-Linux-1c30ef | exact |
| 9 | entry aged out of the store | false | empty | miss |
| 10 | nothing | false | npm-Linux- | partial |
Five runs recorded exact, three recorded partial, two recorded miss. The strict rate is 5 of 10, or 50 percent. The restore rate is 8 of 10, or 80 percent. Both describe the same ten runs, and quoting either one without the definition leaves the reader guessing which cache behavior is being described.
The split also points at different work. Runs 4 and 7 are the cache working as designed, since the dependency set changed and a prefix fallback covered most of the install. Run 9 is the one worth chasing, because the key was unchanged since run 8 and the entry was gone anyway. Grouping the summary lines by the cache= field keeps an npm cache from being averaged against a Docker layer cache in the same repository. Aggregating those lines across a workflow's run history is covered on the sibling page below.
Related Terms
- Cache eviction and why entries disappear under a stable key: the storage limit and the access-age rule that turn a stable key into an intermittent miss, which is the usual cause of a rate that decays over time.
- How to measure cache hit rate in GitHub Actions: pulling the recorded outcomes out of run history and turning them into a per-prefix rate.
- Why a GitHub Actions cache misses on every run: the diagnosis path for a rate stuck near zero, where the key changes on every evaluation.
- GitHub caching documentation: the upstream reference for restore order, the storage limit, and the access-age rule.
- WarpBuild caching documentation: cache action inputs and the
cache-hitoutput behavior. - WarpBuild reports documentation: per-job duration and queue-time percentiles, which is where a hit rate gets checked against elapsed time.
- WarpBuild pricing: per minute rates by runner type.
FAQ
What counts as a cache hit?
Whatever the person quoting the number says it counts as. Two definitions are in common use: an exact match against the primary key, and any restore that returned bytes including a prefix fallback. The second is the looser of the two and lands at or above the first on any given set of runs, so the definition has to travel with the figure.
What is a good cache hit rate?
There is no single target, because the achievable rate depends on how often the cached contents legitimately change. A dependency cache keyed on a lockfile that changes twice a month should sit near the top of its range, while a cache keyed on a source tree hash in an active repository will miss most of the time by design.
Should cache hit rate be measured per repository or per cache?
Per key prefix, then rolled up per workflow. One repository usually holds several caches with different change rates, and a single repository-wide number averages them into a figure that no fix moves.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.