Cache and cost strategy: restore-keys hierarchy, the 10 GB eviction wall, and Docker layer cache
GitHub Actions cache gives each repo a 10 GB store with LRU eviction. A precise key plus broad restore-keys turns a cold cache warm; Docker layer caching via the gha backend caches build layers, but an oversized cache that evicts itself can cost more than no cache at all.
The team had added dependency caching to speed up CI, and for a week it worked — installs that took 90 seconds dropped to 8. Then the cache hit rate quietly collapsed to near zero and nobody noticed until the bill for runner minutes crept back up. The cause was the 10 GB-per-repo cache ceiling and LRU eviction: someone had started caching the entire node_modules and the Docker build cache and a Cypress binary cache, each keyed so loosely that every branch wrote a fresh 2 GB entry. Within a day the repo’s caches blew past 10 GB, and LRU started evicting the dependency cache — the one with the highest hit rate — to make room for per-branch Docker caches that were each used once. The team was paying the write cost of caching with none of the read benefit. The fix was not more cache; it was less: drop the per-branch Docker cache to a single main scope, tighten the keys, and let the high-value dependency cache survive eviction.
The key and the restore-keys ladder
When you add caching to CI you are making a bet: “this will be read more than it costs to write.” The key and restore-keys structure is what determines whether that bet pays off or silently loses money every run.
The cache action restores by a primary key and a list of restore-keys fallbacks:
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-The key is an exact match: hashing the lockfile means the key changes only when dependencies change, so an unchanged lockfile gets a perfect hit and a changed one gets a clean miss (no stale deps). On a miss, restore-keys are tried as prefix matches, most-recent-first: npm-Linux- restores the newest cache from any prior lockfile, so a one-line dependency bump still warm-starts from yesterday’s node_modules and only installs the delta. The pattern is specific key, broad fallback — exact for correctness, prefixes for warmth. A cache writes a new entry only on a key miss; if the exact key hit, no new cache is saved.
A workflow keys its dependency cache on hashFiles('package-lock.json') with a restore-key prefix of npm-Linux-. A PR adds one new dependency, changing the lockfile. What happens on that run?
The 10 GB wall and LRU eviction
Every repository gets a 10 GB total cache budget. When writes push it past 10 GB, GitHub evicts entries by least-recently-used — and caches are also evicted if untouched for 7 days. Two consequences drive real cost:
- Branch scope: a cache created on a branch is readable by that branch and by child branches; the default branch’s caches are readable by all branches. So caching on
mainseeds every PR, while caching per-feature-branch produces low-reuse entries that just consume the 10 GB budget. - Eviction is silent and value-blind: LRU does not know your dependency cache has a 95% hit rate and your per-branch Docker cache has 1%. If the low-value caches are written more recently, they push the high-value one out. Caching more things can make CI slower by evicting the cache that mattered.
The discipline: cache the few high-value, high-reuse things on the default branch with tight keys, and resist the urge to cache everything. A cache that is written every run but rarely read is pure overhead — you pay the upload time and the eviction pressure for no restore benefit.
▸Why this works
Why does over-caching actively hurt instead of just being neutral? Three compounding costs. First, every cache save uploads the artifact, adding wall-clock time to the job even when it is never restored. Second, every write competes for the fixed 10 GB, so a low-value write can evict a high-value entry, converting a future hit into a miss. Third, the miss it caused is the expensive one — a full cold install or rebuild — so one wasted 2 GB per-branch cache can turn a 95%-hit dependency restore into a 90-second cold install on the next run of a different branch. Cache budget is a shared, scarce, LRU-managed resource; treating it as free leads directly to a slower, costlier pipeline.
Docker layer cache: powerful and easy to misuse
For container builds, BuildKit can use the GitHub Actions cache as a layer-cache backend (cache-from/cache-to: type=gha). Done right it skips rebuilding unchanged layers — a multi-minute image build drops to seconds when only the app layer changed. Two traps: mode=max caches all intermediate layers (great hit rate, but large — easily multiple GB, which eats the 10 GB budget and triggers eviction), while mode=min caches only the final layers (smaller, lower hit rate). And ordering your Dockerfile so dependencies install before code is copied is what makes the cache hit at all — copy code first and every build invalidates the dependency layer. Layer caching is leverage, but a fat mode=max cache that evicts your dependency cache, or a poorly ordered Dockerfile that never hits, costs more than building cold.
A repo's CI got slower after the team added a Docker layer cache with type=gha,mode=max on every feature branch. Dependency-install steps that used to hit cache now often miss. What is the most likely mechanism?
- 01Explain the key vs restore-keys distinction and why the pattern is specific key, broad fallback.
- 02Why can adding more caches make CI slower, and what governs the 10 GB store?
GitHub Actions caching is leverage only when the warm path is actually hit, and that hinges on three things: the key, the fallbacks, and the budget. The primary key should be an exact value — typically a hash of the lockfile — so it hits when nothing changed and cleanly misses when dependencies did, never serving stale state. The restore-keys list provides prefix fallbacks tried most-recent-first, so a changed lockfile still warm-starts from the newest prior cache and installs only the delta: specific key for correctness, broad prefixes for warmth, with a new entry saved only on an exact-key miss. The hard constraint is the store: 10 GB per repository, evicted by least-recently-used and expired after 7 days idle, and eviction is value-blind — it cannot tell your 95%-hit dependency cache from a per-branch Docker cache used once. That is why caching more can make CI slower: a large low-reuse cache competes for the fixed budget and can push out the high-value one, turning a future cheap restore into an expensive cold install, on top of the upload cost every save pays even when never read. Branch scope compounds it — default-branch caches seed every PR, while per-feature-branch entries reuse poorly and just burn budget. Docker layer caching through the type=gha backend is powerful: it skips unchanged layers and drops a multi-minute build to seconds, but mode=max caches every intermediate layer and can grow to multiple GB that evict your dependency cache, and a Dockerfile that copies code before installing dependencies never hits at all. The throughline is restraint: cache a few high-value, high-reuse artifacts on the default branch with tight keys and well-ordered Dockerfiles, and treat the 10 GB as the scarce shared resource it is. Now when you see a CI cache hit rate that collapsed after someone “added more caching,” you know exactly where to look: which new entry is LRU-evicting the one that actually mattered.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.