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.
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-keysfor 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
- Turn on
org.gradle.cachingand check the scan for cache misses. Fix the non-deterministic inputs. - Remove
cleanfrom CI. - Add the remote cache.
- Scope PR tests to affected modules.
- 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.