Why Build Speed Matters More Than You Think
| Change applied to Gradle's reference project | Reported build time |
|---|---|
| Baseline, Gradle 8.10 | 21 seconds |
| Updated to Gradle 8.13 | 8 seconds |
| Parallel execution | 7 seconds |
| Build cache | 5 seconds |
| Daemon | 3 seconds |
How do you find out where the time goes?
# A Build Scan: a web report of where the time went
./gradlew assembleDebug --scan
# A local profile report
./gradlew assembleDebug --profile
# The comparison that tells you whether incremental
# builds are working at all
./gradlew clean assembleDebug # everything
./gradlew assembleDebug # nothing changedConfiguration Cache and Parallel Execution
org.gradle.configuration-cache.problems=warn, left on permanently. Gradle's position: "Warning mode is a migration and troubleshooting aid and not intended as a persistent way of ignoring incompatibilities."
2. org.gradle.daemon=true. "Gradle enables the Daemon by default".
3. android.nonTransitiveRClass=true. Non-transitive R classes are worth having, and "This is the default behavior in Android Gradle plugin 8.0.0 and higher."
4. The android.defaults.buildfeatures.* flags. The AGP 9.0 release notes record the removal of the aidl, renderscript and buildconfig defaults flags.# gradle.properties
# Off by default: run tasks from different projects in parallel
org.gradle.parallel=true
# Off by default: reuse task outputs for the same inputs
org.gradle.caching=true
# Opt-in until Gradle 10
org.gradle.configuration-cache=true
# Heap for the Gradle daemon. Raise it only if the Build
# Analyzer shows garbage collection above 15% of build time.
org.gradle.jvmargs=-Xmx4g
# Not needed, do not add:
# org.gradle.daemon=true (already the default)
# android.nonTransitiveRClass=true (default since AGP 8.0)
# org.gradle.configuration-cache.problems=warn
# (a migration aid, not a setting to keep)Dependency Management for Faster Resolution
1.+ makes Gradle check the repositories on every build. That overstated it: "By default, Gradle caches dynamic versions for 24 hours". The real cost is broader, in Android's words: "Using dynamic version numbers can cause unexpected version updates, difficulty resolving version differences, and slower builds caused by Gradle checking for updates." Pin versions. A version catalog is the place to do it, and the version catalogs guide has a full one, which is why this article no longer carries its own stale copy.
Too many repositories. Gradle: "Limit the number of repositories to the minimum required." Declare the one that serves most of your dependencies first. For an Android app that is google().
kapt. "The Kotlin Annotation Processing Tool (kapt) is significantly slower than the Kotlin Symbol Processor (KSP)." If a library you use supports KSP, move it.// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google() // serves most Android dependencies: first
mavenCentral()
}
}
// build.gradle.kts of a module that uses Room
dependencies {
implementation(libs.room.runtime)
ksp(libs.room.compiler) // not kapt(...)
}Module Structure and Build Avoidance
implementation versus api. Gradle calls it out in the same guide: "Switching to implementation for internal-only dependencies is one of the most impactful changes you can make to improve build performance in large, modular codebases." The Java Library plugin documentation gives the mechanism: "less recompilations when implementation dependencies change: consumers would not need to be recompiled".
Reserve api for a type that appears in the module's own public signatures. Everything else is implementation. How to draw the module boundaries in the first place is the subject of the modularization guide.
The sample no longer applies the kotlin-android plugin. AGP 9 compiles Kotlin itself and rejects it.// core/model/build.gradle.kts
plugins {
id("com.android.library")
}
dependencies {
// api: Instant appears in this module's public signatures,
// so consumers need it on their compile classpath
api(libs.kotlinx.datetime)
// implementation: invisible to consumers. A change here
// does not recompile the modules that depend on this one.
implementation(libs.kotlinx.serialization.json)
}
// feature/tasks/build.gradle.kts
dependencies {
implementation(project(":core:model"))
implementation(project(":data:tasks"))
}CI-Specific Optimizations
--no-daemon costs nothing. On a self-hosted agent that stays up, leave the daemon on.
Caching is where CI time is won. The setup-gradle action restores and saves the Gradle caches, and its documentation describes the default: it "will only write to the cache from Jobs on the default (main/master) branch". Other branches read. The earlier sample set a cache-read-only expression to get that behaviour, which the action already has.
The workflow below uses the current major versions of each action and gives the job a timeout, for the reasons in the GitHub Actions CI/CD guide.# .github/workflows/android-ci.yml
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
distribution: zulu
java-version: 17
# Restores the Gradle caches. Writes them back only
# from the default branch.
- uses: gradle/actions/setup-gradle@v6
- name: Build and test
run: ./gradlew assembleDebug testDebugUnitTest --no-daemon
- name: Upload build reports
if: failure()
uses: actions/upload-artifact@v7
with:
name: build-reports
path: "**/build/reports/"Key Takeaways
- 1Inspect the build with a Build Scan or profile report before changing anything, then change one thing and inspect again.
- 2Parallel execution and the build cache are off by default. The configuration cache is opt-in until Gradle 10.
- 3Do not keep configuration cache warning mode on. Gradle calls it a migration and troubleshooting aid.
- 4Pin dependency versions, keep the repository list short and replace kapt with KSP where the library supports it.
- 5Prefer implementation over api so a change inside a module does not recompile its consumers.
- 6On CI, let setup-gradle manage the cache and give every job a timeout.
Frequently Asked
Why is my Gradle configuration phase so slow?
The configuration phase runs on every build, whichever tasks you ask for, so anything expensive in a build script is paid each time. Enable the configuration cache so Gradle can skip the phase when its inputs are unchanged, apply plugins only where they are needed, and move shared logic into convention plugins.
Is the Gradle configuration cache enabled by default?
Not yet. The Gradle documentation says the configuration cache has been the preferred mode of execution since Gradle 9.0.0 and will be enabled by default in Gradle 10. Until then, set org.gradle.configuration-cache=true in gradle.properties. IDE imports and syncs do not use it, so it speeds up builds, not project sync.
Should I pass --no-daemon on CI?
It depends on the runner. Gradle states that on CI machines long-lived agents benefit from the daemon and short-lived ones may not. A hosted runner that is discarded after each job gains nothing from a warm daemon, so --no-daemon is reasonable there. A persistent self-hosted agent should keep the daemon enabled.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.