CI Dependency Caching (cache keys & restore-keys)
your cache is a hash table, not a folder
Developer toolingdeveloper tooling / CI
The mental model
A CI cache entry is an immutable key → blob pair. You write once under an exact key; you never edit it. Think Redis SETNX, not a shared folder that syncs.
Everything confusing about CI caching falls out of that one sentence.
The concrete thing (GitHub Actions)
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
Two fields, two different jobs:
- key = EXACT match. Hit → restore, and at the end of the job nothing is saved (the entry already exists).
- restore-keys = PREFIX match, newest entry first. A "close enough" starting point. On a restore-key hit the job still saves a NEW entry under the exact key when it finishes.
That's why a lockfile change costs you one slow run, then goes fast again.
Where the "folder" analogy breaks
- Immutability. Same key, different content → the save is a silent no-op ("Cache already exists"). Cache a build output like dist/ under a key that doesn't hash your source, and you will serve a stale artifact forever, green CI and all.
- Scope. A branch can read caches from its base/default branch, but not from sibling branches. That's the real answer to "why is main fast and every PR slow?"
- Eviction. GitHub: 10 GB per repo, LRU, plus anything untouched for 7 days is deleted. Your cache is a hint, never a guarantee — the build must still work with zero hits.
The two mistakes practitioners actually make
Mistake 1 — caching the wrong directory.
Pick a lane: cache ~/.npm (the download cache) and run npm ci, OR cache node_modules and skip install. Caching node_modules and still running npm ci is pure waste — npm ci deletes node_modules before it starts.
Mistake 2 — loose restore-keys on an exact-state directory.
Ask: is this cache additive or exact-state?
- Additive (~/.npm, ~/.cargo/registry, pip's http cache): extra stale files are harmless. Loose prefix keys are great.
- Exact-state (node_modules, .venv, target/): a prefix hit hands you a tree built from a DIFFERENT lockfile. Removed packages linger on disk, your import keeps resolving, CI stays green — and prod dies because that package isn't in the lockfile anymore.
Rule of thumb: restore-keys are safe for caches you only add to, dangerous for caches that represent a state.
Rough numbers, mid-size Node repo:
- npm ci cold ≈ 60s
- npm ci with ~/.npm restored ≈ 18s
- restore node_modules, skip install ≈ 5s ← fastest and the one that can lie to you
Your exercise
Open your CI config, find one cache step, and answer two questions:
(a) If I delete a dependency from the lockfile today, will the next run still have it sitting on disk?
(b) Does this job save a new entry when only a restore-key matched, or does it re-download every single time?
If your answer to (a) isn't a confident "no", your restore-keys are too loose for what you're caching.