Skip to content
Latchkey LogoLatchkey home

Cache Gradle GitHub Actions builds, and Maven, with numbers

To cache Gradle GitHub Actions runs, use gradle/actions/setup-gradle, which keeps the whole Gradle User Home rather than the dependency folder alone; for Maven, use actions/setup-java with cache: maven, which is a managed actions/cache over ~/.m2/repository. On one small Spring project measured on a Latchkey runner, the Gradle cache was worth 22.0 seconds a run and the Maven cache 10.3 seconds.

Bars: Maven 14.1 s cold against 3.8 s warm, Gradle 29.3 s cold against 7.3 s warm
The same two builds on a latchkey-small runner on 2026-09-20, from job-g.sh. Emptying the dependency directory is the only thing that changes between the members of each pair.

JVM builds are the case where dependency caching earns its reputation. A cold Maven or Gradle run downloads a few hundred jars before it compiles a line, and unlike a Node install the work is not evenly spread: it is one long stall at the start of the job with nothing useful happening after it.

The two tools want different handling, and the difference is not cosmetic. Gradle keeps far more than dependencies in its home directory, which is why its own action caches the directory rather than a subfolder of it, and why layering a second cache on top of that action makes things worse rather than better.

What each action actually caches

Both build tools keep a per-user directory that survives between builds on a developer machine and does not exist at all on a fresh runner. Restoring it is the whole trick. What differs is how much lives in that directory and who is willing to manage it for you.

For Maven the answer is simple, because ~/.m2/repository holds downloaded artifacts and nothing else. For Gradle the directory holds the dependency cache, the wrapper distributions, generated jars, transformed artifacts and the local build cache, and the official action curates which of those to keep.

ToolUseWhat it keepsKeyed on by default
Mavenactions/setup-java@v6 with cache: maven~/.m2/repositorysetup-java-<runner-os>-<node-arch>-maven-<file-hash>, hashing every /pom.xml, /.mvn/wrapper/maven-wrapper.properties and **/.mvn/extensions.xml
Gradlegradle/actions/setup-gradle@v6A curated subset of the Gradle User Home: caches/modules-2, caches/transforms-3, caches/jars-9, caches/build-cache-1, plus downloaded distributionsThe OS, the job id, the workflow name, matrix parameters and the git SHA
Either, by handactions/cache@v6 on the directoryWhatever path you listWhatever key you write

What the restore is worth, measured both ways

We ran each build twice on a Latchkey latchkey-small runner against a project with one Spring Boot web dependency and its test starter: once with the tool's dependency directory deleted, then with it left in place. Nothing else changed between the members of a pair. Same project, same runner, same command, same JDK, same job.

Maven went from 14.1 seconds to 3.8. Gradle went from 29.3 seconds to 7.3. Those are the two numbers worth carrying away, and the ratio is the part that transfers: both tools spend roughly three quarters of a cold build downloading, on a project small enough that the compile is almost free.

Build, control held: same project, runner, command and JDKDurationRestored directory
mvn verify, ~/.m2/repository emptied14.1 snothing
mvn verify, ~/.m2/repository restored3.8 s55 MB in 895 files
mvn verify, restored, second pass3.8 sthe same
mvn -o verify, restored and fully offline3.9 sthe same
gradle build, ~/.gradle/caches emptied29.3 snothing
gradle build, ~/.gradle/caches restored7.3 s29 MB, of which 23 MB is modules-2
gradle build, restored, second pass7.2 sthe same

Gradle: use the action, and do not stack a cache on top of it

The action does the caching itself, and its documentation is blunt about what to avoid. It says to avoid using actions/cache on the Gradle User Home, and to avoid actions/setup-java with cache: gradle, because either one fights the action for the same directory. The result of doing both is not a faster build, it is two writers for one path and a restore you cannot reason about.

The offline row above is the reason the dependency cache is where the value sits rather than the build cache. Adding --build-cache to the warm Gradle build moved it from 7.3 seconds to 7.2, and a second pass with the build cache populated got to 6.5. On a project that compiles one class that is what you would expect: there is nothing to reuse. On a real module graph the build cache is worth having, and it is inside the directory the action already keeps.

.github/workflows/ci.yml
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
  with:
    distribution: temurin
    java-version: '21'

# no cache: gradle above, and no actions/cache step anywhere
- uses: gradle/actions/setup-gradle@v6
- run: ./gradlew build

The rule that keeps your feature branches cold

This one surprises people and it is documented behavior, not a bug. By default setup-gradle only writes cache entries from jobs on the default branch. Jobs on other branches read entries and never write updated ones. That is a deliberate defense against one branch poisoning everyone's cache, and it means a long-lived feature branch that adds dependencies will restore a stale entry and download the difference on every single run.

The Actions cache scoping underneath works the same way for the hand-rolled case: a run restores entries from its own branch and from the default branch, and a pull request also reads its base branch. If nothing ever writes on the default branch, nothing has anything to restore. Push to the default branch, or run a small scheduled job there, and every branch below it starts warm.

.github/workflows/warm-gradle-cache.yml
on:
  push:
    branches: [main]
  schedule:
    - cron: '0 6 * * 1-5'

jobs:
  warm:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v6
        with: { distribution: temurin, java-version: '21' }
      - uses: gradle/actions/setup-gradle@v6
      - run: ./gradlew build --dry-run

Maven: the managed option, and the hand-rolled one

For most repositories cache: maven on setup-java is the whole answer. It hashes your pom.xml files into the key, restores ~/.m2/repository, and exposes cache-hit so you can see whether the match was exact. You write two lines and never think about it again.

Reach for actions/cache directly when you need to exclude something, and there is one exclusion worth making. Your own modules are installed into the same repository directory as your dependencies, so a cached tree can hand a later run a stale build of your own artifact. Our measured repository held 0 SNAPSHOT files for this project and 225 _remote.repositories marker files, which is what Maven uses to remember where an artifact came from; the group you publish under is the thing to leave out.

.github/workflows/ci.yml
- uses: actions/setup-java@v6
  with: { distribution: temurin, java-version: '21' }

- uses: actions/cache@v6
  id: m2
  with:
    path: ~/.m2/repository
    key: m2-${{ runner.os }}-jdk21-${{ hashFiles('**/pom.xml') }}
    restore-keys: |
      m2-${{ runner.os }}-jdk21-

- run: rm -rf ~/.m2/repository/com/example/ourgroup
- run: mvn -B -ntp verify

What we ran, so you can disagree with it

One script, job-g.sh, run once on a Latchkey latchkey-small runner on 20 September 2026, committed under content/repro/timings/cache-gradle-and-maven-in-github-actions/. The script, the unedited output as job-g.log, and a job-g.status.json naming the job id, the runner size and the exit code are all committed beside each other, so every digit above can be checked rather than trusted.

The caveats belong on the page rather than in a footnote. One pass per row means the seconds carry runner-level noise, and a gap under about half a second is not a real difference, which is why the offline Maven row and the Gradle build-cache row are reported as no change rather than as small ones. The project is deliberately small, so the compile is almost free and the download share of a cold build is at the top of its realistic range. And the two tools ran different commands against different task graphs, so the honest comparison is cold against warm inside each tool, never Maven against Gradle.

Frequently asked questions

Should I use setup-gradle or actions/cache for the Gradle User Home?
Use setup-gradle and nothing else. Its documentation says outright to avoid actions/cache configured for the Gradle User Home and to avoid setup-java with cache: gradle, because both write the same directory the action manages. The action also keeps the local build cache and the wrapper distributions, which a hand-rolled cache on the dependency folder alone would miss.
Why does my Gradle cache grow every time I update the wrapper?
Because each wrapper version downloads its own distribution and its own generated jars into the Gradle User Home, and the old ones are still there. setup-gradle runs a cache cleanup after a successful build to drop entries the build no longer used, controlled by cache-cleanup. If you cache the directory by hand instead, nothing prunes it and the entry grows until it starts evicting your other caches.
Why is Maven caching not working with my Maven profiles?
A profile that changes which dependencies resolve does not change your pom.xml files, so the default key is identical across profiles and both share one entry. Put the profile in the key yourself, either through cache-dependency-path on setup-java or by writing the key on an actions/cache step directly. Two profiles sharing one cache is a restore that looks like a hit and is missing artifacts.
Why is my feature branch still cold when the default branch is cached?
Two things stack here. setup-gradle only writes cache entries from the default branch by default, and the Actions cache is scoped so a run reads its own branch, the default branch and, on a pull request, the base branch. A feature branch that has added dependencies since the default branch was last built will restore the older entry and download the difference every run, which is a partial hit rather than a failure.

Related guides

References

A cold JVM build is three quarters download. Latchkey Fast Cache restores the same directory on your key. Start free → 30-day trial · No credit card