Cache Eviction
Cache eviction is the removal of cache entries to hold a store under a size ceiling or an age limit. How least recently used order plays out in GitHub Actions.
Cache eviction is the removal of entries from a cache store so the store stays under a size ceiling or an age limit, usually taking the least recently used entry first. The store performs it on its own schedule, so a key that hit yesterday can miss this morning with nothing changed in the workflow that asked for it.
That split is what makes cache behavior look random from inside a workflow file. The workflow chooses what to store and under which key. Whether the entry survives until the next run is decided by everything else that wrote to the same store in between.
Definition
A cache holds results that can be recomputed, inside a fixed budget. Entries keep arriving and the budget does not grow, so something has to leave. Eviction is the rule that picks what leaves.
Two triggers cover almost every implementation.
Capacity pressure. The store has a size ceiling. When a new entry pushes the total past it, the store accepts the write and then deletes existing entries until the total fits again.
Age. An entry that has gone unread for a set window is deleted whatever the total size. Most build caches use a sliding window measured from the last read, so an entry restored every weekday stays alive and an entry written once and never restored ages out.
Removal order is the policy. Least recently used ranks entries by the time of their last read and removes from the oldest end, which is the common default because a recent read is a cheap prediction of a future read. Other stores rank by write time, by read frequency, or by size, and a size-weighted policy removes one large entry instead of many small ones.
Four words get used for the same visible symptom, and separating them is most of the diagnosis:
| Term | What removes the entry | Timing | Decided by |
|---|---|---|---|
| Eviction | The store, under capacity pressure | When some other write does not fit | Traffic from every writer sharing the store |
| Expiration | The store, under an age rule | A fixed window after the last read or write | The store's retention policy |
| Invalidation | Nothing is removed; a new key addresses a new entry | On the next run that computes a different key | The workflow author |
| Deletion | An explicit API or console call | On demand | An operator |
Invalidation is the odd one. Entries in a content addressed store are immutable, so a changed key writes a fresh entry beside the old one rather than replacing it. The superseded entry keeps holding space until eviction or expiration collects it.
How the two triggers appear in GitHub Actions
GitHub applies both. The combined size of all caches in a repository is capped at 10 GB by default, entries are evicted in least recently used order once the total passes that cap, and any entry that goes 7 days without use is deleted regardless of the total (GitHub dependency caching reference, checked on 2026-08-13).
Two properties of that budget cause most of the surprise. The ceiling is per repository, so a nightly workflow exporting large layer caches competes for space with the dependency cache every pull request restores. And entries are scoped per branch, so four active branches running the same workflow hold four copies of a mostly identical tree, each one counting in full.
Neither the cap nor the age rule is visible from inside a job. The Caches view under the repository's Actions tab and gh cache list --sort last_accessed_at show the size and last read time of every entry, and the REST endpoint GET /repos/{owner}/{repo}/actions/cache/usage returns the active entry count and the total size in bytes that the cap applies to.
Example
Take a repository with four active branches. Each branch writes a Node dependency cache of about 2.2 GB, and a nightly workflow exports a Docker layer cache of about 3.5 GB into the same store. Those sizes are an assumption for illustration; read your own with gh cache list --sort size_in_bytes.
The dependency cache comes from an ordinary cache step, with no eviction handling anywhere in the file:
name: test
on:
push:
pull_request:
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@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
- run: npm ci
- run: npm testTrace one day of writes against the 10 GB ceiling. Every branch resolves the same key text, and each branch scope holds its own entry:
| Time | Event | Written | Store total | Evicted |
|---|---|---|---|---|
| Mon 09:00 | Push to main | 2.2 GB on main | 2.2 GB | None |
| Mon 11:00 | Pull request A | 2.2 GB on branch A | 4.4 GB | None |
| Mon 14:00 | Pull request B | 2.2 GB on branch B | 6.6 GB | None |
| Mon 18:00 | Pull request C | 2.2 GB on branch C | 8.8 GB | None |
| Mon 23:00 | Nightly layer cache export | 3.5 GB | 7.9 GB after eviction | Main entry, last read 09:00, and branch A entry, last read 11:00 |
| Tue 09:15 | Push to main | 2.2 GB on main, rebuilt cold | 7.9 GB after eviction | Branch B entry, last read Mon 14:00 |
Tuesday morning's main build is the job that expected a hit. Its lockfile did not change, its key resolved to the same string as Monday's, and the restore step still logged a miss, because the entry it wanted was deleted at 23:00 to make room for a cache belonging to a different workflow. npm ci then resolved and downloaded the whole dependency tree, and the entry it saved evicted branch B's copy on the way in.
That is the treadmill: a store held at its ceiling by writers that outpace reads, where every job pays the cold path and immediately evicts the next job's warm start. It repeats until the working set fits or the writers slow down.
Three signals separate eviction from a key problem before you edit anything:
- The restore step logs a miss on a key that hit on an earlier run, with no change to the lockfile, the runner image, or the
pathlist. gh cache listshows a total sitting near the cap, and the missing entry's last read was older than the entries that survived.- The repository sits far under the cap and the missing entry had gone 7 days without a read, which points at the age rule instead.
Related Terms
- Cache hit rate: the metric that eviction moves, and how to read a falling hit rate against a store that is full rather than misconfigured.
- The GitHub Actions cache size limit, explained: where the 10 GB ceiling comes from, how least recently used order picks a victim, and what to do when a working set does not fit under it.
- How to clear a GitHub Actions cache: removing entries deliberately through the Caches view, the GitHub CLI, and the REST API.
- GitHub dependency caching reference: the documented cap, the eviction order, and the 7 day age rule.
- WarpBuild caching documentation: cache action inputs, the restore and save split, and key examples.
- Cache usage reporting: the reports view listing cache storage and cache operations per day.
- WarpBuild pricing: per minute rates by runner type.
FAQ
What is cache eviction?
Cache eviction is the removal of entries from a cache store so the store stays inside its budget. Two triggers cover most implementations: capacity pressure, where a new write does not fit under the size ceiling, and age, where an entry that has gone unread for a set window is deleted. The usual removal order is least recently used first.
What is the difference between eviction and expiration?
Expiration is driven by a clock: an entry is removed once it has gone unread for a fixed window, whatever the store holds. Eviction is driven by pressure: an entry is removed because another write needs the space. An expiring entry has a predictable removal date, while an evicted entry disappears at a moment decided by other workflows writing to the same store.
Why did my GitHub Actions cache disappear without the key changing?
Either the repository passed its cache size cap and the entry was evicted in least recently used order, or the entry went 7 days without a read and was deleted by the age rule. Check the total size and the last accessed timestamps with gh cache list before touching the workflow, because neither cause is visible in the key.
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.