# Cache Gradle GitHub Actions builds, and Maven, with numbers

> Cache Gradle GitHub Actions builds with setup-gradle and Maven with setup-java, measured cold against warm on one runner, with the keys that hold.

Source: https://latchkey.dev/learn/speed/cache-gradle-and-maven-in-github-actions  
Updated: 2026-09-20

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.

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.

| Tool | Use | What it keeps | Keyed on by default |
| --- | --- | --- | --- |
| Maven | `actions/setup-java@v6` with `cache: maven` | `~/.m2/repository` | `setup-java-<runner-os>-<node-arch>-maven-<file-hash>`, hashing every `**/pom.xml`, `**/.mvn/wrapper/maven-wrapper.properties` and `**/.mvn/extensions.xml` |
| Gradle | `gradle/actions/setup-gradle@v6` | A curated subset of the Gradle User Home: `caches/modules-2`, `caches/transforms-3`, `caches/jars-9`, `caches/build-cache-1`, plus downloaded distributions | The OS, the job id, the workflow name, matrix parameters and the git SHA |
| Either, by hand | `actions/cache@v6` on the directory | Whatever path you list | Whatever key you write |

> Quoted from the `actions/setup-java` and `gradle/actions/setup-gradle` documentation on GitHub, both read on 2026-09-20. `cache-dependency-path` on `setup-java` overrides the files used for key hashing, which is what a monorepo with several `pom.xml` files needs.

## 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 JDK | Duration | Restored directory |
| --- | --- | --- |
| `mvn verify`, `~/.m2/repository` emptied | 14.1 s | nothing |
| `mvn verify`, `~/.m2/repository` restored | 3.8 s | 55 MB in 895 files |
| `mvn verify`, restored, second pass | 3.8 s | the same |
| `mvn -o verify`, restored and fully offline | 3.9 s | the same |
| `gradle build`, `~/.gradle/caches` emptied | 29.3 s | nothing |
| `gradle build`, `~/.gradle/caches` restored | 7.3 s | 29 MB, of which 23 MB is `modules-2` |
| `gradle build`, restored, second pass | 7.2 s | the same |

> Measured by `job-g.sh`, under content/repro/timings/cache-gradle-and-maven-in-github-actions/, on a Latchkey `latchkey-small` runner on 2026-09-20: 2 vCPU, 7,734 MB RAM, OpenJDK 17.0.20.1, Maven 3.9.14, Gradle 9.4.1. One pass per row. Gradle ran with `--no-daemon` on every row, because a CI job never inherits a daemon from the job before it, and both tools skipped test execution so the rows differ only in what had to be downloaded. Read the pairs within a tool, not across them: the two commands do different work.

## 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
```

> The `-ntp` flag drops Maven's transfer progress lines, which on a cold run are several thousand lines of log you will never read. `-B` puts Maven in batch mode so it never waits for input.

## 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.

## FAQ

### 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.

## References

- [gradle/actions: setup-gradle caching documentation (verified 2026-09-20)](https://github.com/gradle/actions/blob/main/docs/setup-gradle.md)
- [actions/setup-java: cache input and cache-dependency-path (verified 2026-09-20)](https://github.com/actions/setup-java)
- [GitHub Docs: caching dependencies to speed up workflows (verified 2026-09-20)](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
