# GitHub Actions Caching: How the Cache Works, and Why It Stops Working

> How actions/cache stores and restores dependencies, what the 10 GB per-repository limit and 7-day eviction really mean, and why a cache that used to hit suddenly misses.

Source: https://latchkey.dev/learn/optimize-ci/github-actions-caching-explained  
Updated: 2026-08-20

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.

```.github/workflows/ci.yml
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-
```

> The `hashFiles` call is what makes the key change when the lockfile changes. Keying on a branch name or a static string gives you a cache that never updates, which is worse than no cache: you install on top of stale content.

## The three limits that break caches

| Limit | Value | What it looks like when you hit it |
| --- | --- | --- |
| Total cache size | 10 GB per repository, every plan | Oldest entries are evicted without warning to make room. A large Docker or Rust target cache can evict everything else on its own. |
| Eviction on age | Entries unused for 7 days are removed | Monday builds miss on a repository that is quiet at weekends, so the first run of the week is always slow. |
| Branch scope | A cache is readable from the branch that wrote it and from its base branch | A feature branch cannot read another feature branch cache. Warm the cache on the default branch or every PR starts cold. |

> The 10 GB cap is per repository and separate from artifact storage, which is billed against your plan allowance. Cache storage is not billed; it is capped.

## Why a cache that used to hit starts missing

- The lockfile changed, so the hash changed. Expected, and the reason `restore-keys` exists 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.os` in 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.

```.github/workflows/ci.yml
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
  with:
    cache-from: type=gha
    cache-to: type=gha,mode=max
```

> `mode=max` exports intermediate layers as well as the final image, which caches more and consumes more of the same 10 GB budget. On a repository that also caches dependencies, `mode=max` is the usual reason everything else gets evicted.

## Ordering 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.

```Terminal
# 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
```

> Compare a cold-cache run against a warm one. If most of the difference is install time, caching is the win; if it is not, caching will change nothing and the build itself needs the attention.

## FAQ

### How much cache storage do GitHub Actions include?

Ten GB per repository on every plan, shared across all cache entries for that repository. It is a cap rather than a billed allowance: you are not charged for cache storage, but once the repository crosses 10 GB, the least recently used entries are evicted silently to make room.

### How long does a GitHub Actions cache last?

An entry is removed after 7 days without being read, and can be evicted sooner if the repository hits the 10 GB cap. Both happen without a warning in the log, which is why a build that was fast last month can be slow today with no change to the workflow.

### What is the difference between actions/cache and artifacts?

A cache is an optimisation keyed for reuse by later runs and capped at 10 GB per repository, free but evictable. An artifact is an output you intend to keep or download, retained for a configured number of days and billed against your plan storage allowance. Using an artifact as a cache is expensive; using a cache as durable storage loses data.

### Why does my cache miss on a pull request?

Caches are scoped by branch. A pull request branch can read entries written by itself and by its base branch, but not entries written by a sibling branch. If the cache is only ever written on feature branches, every new branch starts cold. Write it on the default branch as well.

### Does caching reduce my CI bill?

It reduces billed minutes, because a restored dependency tree removes the install time from every job that would otherwise repeat it. It does not reduce the per-minute rate. The two levers are independent: caching cuts the minutes, and the runner you choose sets what each minute costs.

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
