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.

DenominatorWhat the rate then answersWhere it misleads
Every execution of the restore stepHow often the store served a requestA matrix job that fans out to eight variants contributes eight lookups for one logical build
Every job that could use the cacheHow often a job started warmJobs that restore two caches collapse into one data point
Every unique key requested in the windowHow well the key design matches the change rateA 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:

RunWhat changedcache-hitcache-matched-keyOutcome recorded
1first run, store emptyfalseemptymiss
2nothingtruenpm-Linux-9f2c1aexact
3nothingtruenpm-Linux-9f2c1aexact
4one dependency addedfalsenpm-Linux-partial
5nothingtruenpm-Linux-4b77deexact
6nothingtruenpm-Linux-4b77deexact
7dependency bumpfalsenpm-Linux-partial
8nothingtruenpm-Linux-1c30efexact
9entry aged out of the storefalseemptymiss
10nothingfalsenpm-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.

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.