5 min read

Cutting Gradle CI Time Without Buying Bigger Machines

Slow Android CI is usually a caching problem, not a hardware one. The remote build cache, dropping clean, running only affected tests, caching the right directories, and the configuration cache — with the measurement discipline that keeps you honest.

AndroidGradleCI/CDBuild PerformanceGitHub Actions
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — Before upgrading runners, check whether your build repeats work it has already done. The order of impact: enable a remote build cache, stop running clean, run only the tests affected by a PR, cache ~/.gradle/caches and ~/.gradle/wrapper keyed on lockfiles, and turn on the configuration cache. Measure every change with a build scan — build tuning is unusually prone to placebo.

Why hardware is the wrong first move

Doubling your runner size roughly halves compilation time and doubles your bill. Enabling the build cache can take an unchanged module's compilation to zero. One scales linearly with spend; the other removes the work.

So the question to keep asking isn't "how do we make this faster?" It's:

Why is this rebuilding something that didn't change?

1. The remote build cache

The single biggest win on a multi-module Android project. Gradle's build cache stores task outputs keyed by their inputs; a remote cache shares those outputs across machines.

// settings.gradle.kts
buildCache {
    local { isEnabled = true }
    remote<HttpBuildCache> {
        url = uri(providers.gradleProperty("buildCacheUrl").get())
        isPush = providers.environmentVariable("CI").isPresent   // CI populates, everyone consumes
    }
}

CI pushes, developers pull. A new hire's first build downloads outputs CI already produced instead of compiling forty modules. A PR touching one feature compiles one feature.

Two things to get right: enable org.gradle.caching=true, and make sure your tasks are actually cacheable — a task with a non-deterministic input (a timestamp, an absolute path, a versionCode from currentTimeMillis()) will miss the cache every single time and look like the cache is broken.

2. Stop running clean

- run: ./gradlew clean assembleDebug test    # guarantees zero reuse

clean is a reflex from an era of less reliable incremental builds. On CI it deletes exactly the outputs your cache just restored, making every other optimisation pointless.

If your build is only correct after a clean, you have a build bug — a task with undeclared inputs or outputs. That's worth fixing, because it's also silently costing your developers locally. clean hides it; it doesn't solve it.

3. Run only the tests that matter for a PR

Full-suite-on-every-PR is the second largest source of wasted CI minutes.

# PR: only modules affected by the diff
- run: ./gradlew testDebugUnitTest --parallel   # scoped to affected modules

# main / nightly: everything
- run: ./gradlew test connectedCheck

Compute affected modules from the changed paths and the project dependency graph, or use one of the Gradle plugins that does it. Combined with feature-based modularization, a typical PR drops from the whole suite to a handful of modules.

Keep the full suite on main so nothing hides — this is about PR latency, not about testing less.

4. Cache the right directories, keyed correctly

- uses: actions/cache@v4
  with:
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
    key: gradle-${{ hashFiles('**/*.versions.toml', '**/gradle-wrapper.properties') }}
    restore-keys: gradle-

Two frequent mistakes:

  • Forgetting ~/.gradle/wrapper — you re-download the Gradle distribution on every run.
  • Keying on the commit SHA — a key that changes every run never hits. Key on the files that determine dependencies, and use restore-keys for a partial fallback.

5. The configuration cache

org.gradle.configuration-cache=true
org.gradle.parallel=true
org.gradle.caching=true

The configuration cache skips re-configuring the whole project on every invocation. On a 40-module build that's real minutes per run, and it applies locally too.

It's strict about what build logic may do — no reading System.getenv() at execution time, no cross-project access at execution time. Those constraints are also what makes subprojects { } incompatible with it, which is one more reason convention plugins are worth the migration.

Measure, or you're guessing

./gradlew assembleDebug --scan     # before
# make exactly one change
./gradlew assembleDebug --scan     # after

A build scan shows task-level timings, cache hits and misses, and why a task wasn't cacheable — which is usually the answer you actually needed.

Change one thing at a time. Build tuning is unusually prone to placebo: you spent an hour on it, so it feels faster, and nobody re-measures.

What order to do this in

  1. Turn on org.gradle.caching and check the scan for cache misses. Fix the non-deterministic inputs.
  2. Remove clean from CI.
  3. Add the remote cache.
  4. Scope PR tests to affected modules.
  5. Enable the configuration cache.

Steps 1–3 are usually most of the win, take an afternoon, and cost nothing per month. That's a better first move than a bigger machine — and if you then still need bigger machines, at least you'll be paying to run work that actually needs doing.

Keep reading