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:

TermWhat removes the entryTimingDecided by
EvictionThe store, under capacity pressureWhen some other write does not fitTraffic from every writer sharing the store
ExpirationThe store, under an age ruleA fixed window after the last read or writeThe store's retention policy
InvalidationNothing is removed; a new key addresses a new entryOn the next run that computes a different keyThe workflow author
DeletionAn explicit API or console callOn demandAn 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 test

Trace one day of writes against the 10 GB ceiling. Every branch resolves the same key text, and each branch scope holds its own entry:

TimeEventWrittenStore totalEvicted
Mon 09:00Push to main2.2 GB on main2.2 GBNone
Mon 11:00Pull request A2.2 GB on branch A4.4 GBNone
Mon 14:00Pull request B2.2 GB on branch B6.6 GBNone
Mon 18:00Pull request C2.2 GB on branch C8.8 GBNone
Mon 23:00Nightly layer cache export3.5 GB7.9 GB after evictionMain entry, last read 09:00, and branch A entry, last read 11:00
Tue 09:15Push to main2.2 GB on main, rebuilt cold7.9 GB after evictionBranch 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 path list.
  • gh cache list shows 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.

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.