The single biggest lever on GitHub Actions build time is usually not a faster runner — it's not redoing work the previous run already did. actions/cache is the mechanism, and the difference between a cache that actually hits and one that silently misses every run comes down to getting the key right.
How actions/cache actually works
The action takes a path (or list of paths) to save and a key to save it under. On a cache hit — an exact key match — the paths are restored before your steps run. On a miss, the job runs normally and, if you're using actions/cache directly (not cache: npm style shortcuts), a new cache is saved under that key at the end of the job, but only if the job succeeds. Caches are scoped per repository and, for pull requests, fall back to caches from the base branch — a PR can read caches from main but a push to main can't read a PR's cache.
Getting the cache key right
The key should be built from something that changes exactly when the cached content should change — almost always a hash of the lockfile. Hashing package-lock.json, yarn.lock, or Cargo.lock means the cache invalidates precisely when dependencies change, not on every commit and not never. restore-keys gives you a fallback: if the exact key misses, it restores the most recent cache matching a key prefix, which is close enough to warm up incremental tools like a compiler cache even when the lockfile changed.
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- run: npm ci
- run: npm test
actions/setup-node, actions/setup-python, and similar setup actions have a cache: input (e.g. cache: 'npm') that wraps this exact pattern for you — correct key hashing included. Reach for actions/cache directly only when caching something the setup action doesn't cover, like a build output directory or a compiler cache.
What belongs in the cache, and what doesn't
Package manager download caches (npm, pip, Maven's .m2, Go's module cache) are the clearest win — they're pure downloaded content keyed cleanly off a lockfile hash. Build artifacts and compiler caches (Rust's target/, Docker layer caches via actions/cache plus buildx, ccache) also cache well but need care with the key, since they can grow large and stale entries just waste cache storage without speeding anything up. node_modules itself is a common one to avoid caching directly — reinstalling from a cached npm store via npm ci is usually faster and safer than restoring a potentially inconsistent node_modules tree.
GitHub evicts caches after 7 days of no access and enforces a per-repository size cap (10 GB total), evicting the least recently used caches once you're over. A workflow must succeed correctly on a full cache miss — never assume the cache will be there.
Cross-job and cross-workflow scoping
Caches saved on a branch are visible to workflow runs on that branch and, for the default branch, to all branches as a fallback via restore-keys. This is why a cache warmed on main speeds up the first run on a new feature branch, but a cache built on a feature branch doesn't leak back into other unrelated branches. Matrix jobs each need their own key component (typically the matrix variable) or they'll fight over — and possibly corrupt — the same cache entry.
| Cache candidate | Key basis | Notes |
|---|---|---|
| npm/yarn/pnpm store | lockfile hash | Use setup-node's built-in cache input |
| pip/poetry | requirements/poetry.lock hash | Use setup-python's built-in cache input |
| Docker layers | Dockerfile + context hash | Pair with buildx cache-from/cache-to |
| Compiler cache (ccache, sccache) | source hash or rolling key | Watch total size vs the 10 GB repo cap |
Wrapping up
Cache keys built from a lockfile hash, with a sane restore-keys fallback, get you most of the win with none of the surprise. Prefer the setup actions' built-in cache: input where it exists, keep matrix jobs from sharing a key, and remember caches can and will be evicted — the workflow has to work correctly without one.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.