Stop Re-Downloading the World: A Practical Guide to GitHub Actions Dependency Caching
CI runners start empty every run, so dependency downloads dominate feedback time. Here's how to key a GitHub Actions cache from your lockfile, with a Maven example and a verification loop.
16 Nov 2025, 20:01 UTC

Your CI job spends the first two minutes downloading the same dependencies it downloaded yesterday. Nothing about them changed — you edited a README — but the runner starts empty every time, so the package manager does its full dance again. Multiply that by every push and every pull request, and a lot of your feedback time is spent waiting for work that produces nothing new.
GitHub Actions dependency caching exists to fix exactly this. The useful takeaway: caching pays off most when the cache key is derived from your lockfile, so dependency changes invalidate the cache while unrelated edits reuse it. Get the key right, and most of the tuning is done.
What the cache actually does
Each Actions runner is a fresh virtual machine. When your job ends, the disk goes away. The cache is a separate store: a step in your workflow can restore a saved directory (like a package manager's local repository) before the build, and save it afterward for future runs.
Two things follow from that design. First, the cache is a performance hint, not a correctness boundary — your build must still resolve dependencies reproducibly when the cache is empty, evicted, or expired. Second, the key you choose decides everything: it determines when a run reuses old data and when it starts fresh.
Key design: lockfiles in, restore-keys as a fallback
The standard pattern hashes your lockfile into the key. A lockfile (for example package-lock.json, yarn.lock, pom.xml plus resolved versions, or Cargo.lock) changes exactly when your dependency set changes, which is precisely when the cache should be discarded.
A restore-keys prefix gives you a partial fallback: if the exact key misses, Actions restores the most recent cache whose key starts with the prefix. That lets a run with changed dependencies start from the previous cache and only fetch the delta, instead of starting from zero.
The trade-off is reuse versus correctness. Broad keys (a static string, or just the OS name) reuse too much and risk stale artifacts leaking into builds. Narrow keys (keying on the commit SHA, say) are safe but miss constantly, so you pay save costs without ever hitting. Lockfile-hash plus a prefix fallback is the middle ground that works for most projects.
A worked example: caching Maven dependencies
Here is a minimal workflow that caches the Maven local repository. It runs on pushes to main and on pull requests:
name: build
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Cache Maven repository
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Build and test
run: mvn -B verifyNotes on the moving parts:
path: ~/.m2/repositoryis where Maven stores downloaded artifacts. For Gradle, the equivalent is typically~/.gradle/cachesand~/.gradle/wrapper; for npm, the cache directory fromnpm config get cache. Each ecosystem differs, so confirm the right directory for your toolchain rather than copying blindly.hashFiles('**/pom.xml')runs on the runner and hashes the matching files, so any dependency change produces a new key.- Pin the action versions you use (
actions/cache@v4at the time of writing) and check current documentation before adopting, since major versions change behavior.
Many setup actions (setup-java, setup-node, setup-python) also offer a built-in cache: option that wraps this pattern. That is fine for simple cases; the explicit actions/cache step gives you more control over paths and keys when you need it.
Verifying it actually helps
Don't assume — measure. The workflow itself tells you what happened:
- Push the workflow and open the run logs. The cache step prints whether it got an exact hit, a restore-key hit, or a miss. The first run should be a miss (a "cold" run); it saves the cache at the end.
- Push a trivial change, like a comment. The second run should report an exact hit. Compare the wall-clock time of the dependency resolution or build step between the cold and warm runs — that delta is your real saving, in your repository, with your network and dependency size.
- Now change a dependency version in the manifest or lockfile and push again. The key should change, the old cache should not be reused, and dependencies should be freshly resolved. If the old cache is reused here, your key is too broad and you have a correctness risk.
- Check the repository's cache usage view (Settings → Caches under Actions, or
gh cache listwith the GitHub CLI) to confirm entries exist and to see how much storage you're consuming. Eviction and retention behavior is governed by current GitHub policy, so verify limits in the docs rather than relying on any number quoted anywhere — including here.
Limitations worth knowing
Caching is not free. Saving and restoring take time, so for tiny dependency sets the overhead can exceed the download it avoids — another reason to measure rather than assume. Caches are also scoped: a cache created on a branch is visible to that branch and its base, but not arbitrarily across everything, which affects how quickly a fresh pull request warms up. And because caches can be evicted, any build that requires the cache to be correct is a bug waiting to happen; treat it purely as a speedup.
Where to start
Pick your noisiest workflow — the one where dependency download dominates the run time — and add a lockfile-keyed cache to just that one. Watch the cache-hit line in the logs over a handful of runs, compare cold versus warm durations, and only then tune keys or expand to other workflows. Caching tuned by measurement beats caching tuned by guesswork every time.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.