GitHub Actions Caching: How the Cache Works, and Why It Stops Working
A GitHub Actions cache is a keyed archive scoped to one repository, capped at 10 GB in total, and evicted after 7 days without a read. Most "the cache stopped working" reports are one of those three facts arriving silently.
Caching in GitHub Actions is not a shared filesystem. Each entry is a tar archive stored against an exact key, uploaded when a job finishes and restored when a later job asks for the same key. Nothing is shared implicitly: if the key differs by one character, you get a miss and a full install.
That design is why caching is both the single largest speed win available in CI and the single most common source of quiet slowdowns. The cache never errors when it misses. It just costs you the minutes it was supposed to save.
How a cache entry is stored and matched
A cache step declares a key and, optionally, a list of restore-keys. On a hit for the exact key, the archive is restored and the save step at the end of the job is skipped. On a miss, GitHub walks restore-keys in order and restores the most recent entry whose key starts with one of those prefixes, then saves a fresh entry under the exact key when the job succeeds.
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-The three limits that break caches
Why a cache that used to hit starts missing
- The lockfile changed, so the hash changed. Expected, and the reason
restore-keysexists as a fallback. - Total size crossed 10 GB and the entry was evicted to make room for a larger one.
- Seven days passed with no read, usually on a repository that is quiet at weekends or over a holiday.
- The runner image or OS changed, so
runner.osin the key resolves differently. - The job that would have saved the cache failed, and the save step only runs on success.
Docker layer caching is a separate problem
The Docker build cache does not live in actions/cache unless you put it there. On a fresh runner the daemon starts with no layers, so every FROM and every RUN re-executes. Buildx can export the layer cache to the Actions cache backend, which is what makes layer caching work across runs.
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
cache-from: type=gha
cache-to: type=gha,mode=maxOrdering the cache steps so they actually help
- Restore before install, save after. A save step placed before the install caches nothing useful.
- Cache the package manager directory, not
node_modules. Restoring a dependency tree built for a different platform is how you get errors that only reproduce in CI. - Give each language its own key and path rather than one combined cache, so one large ecosystem cannot evict the rest.
- Warm the cache on the default branch on a schedule if your repository goes quiet for more than a week.
Measure before you optimise
Pipeline optimisation usually targets the step people assume is slow. Get the real per-step timings first, because the answer is frequently dependency install or a cold cache rather than the build itself.
# per-job timings for the last 20 runs
gh run list --limit 20 --json databaseId,conclusion,createdAt,updatedAt \
--jq '.[] | "\(.conclusion)\t\(.createdAt)\t\(.updatedAt)"'
# per-step timing inside one run
gh run view <run-id> --log | grep -E "^\S+\s+.*Run |##\[group\]" | head -40