A mental model for Gradle
Gradle is a task graph with three phases and a cache. Once you can name the phase a problem happens in — configuration or execution — most build mysteries stop being mysteries.
Gradle is not slow because it is badly written. It is slow because a build is a program that has to answer hard questions — which versions win, which tasks are up to date, which variants exist — and because most build scripts accidentally make it answer those questions more often than necessary.
TL;DR#
- A Gradle build has three phases: initialisation (which projects exist), configuration (which tasks exist), execution (run the tasks).
- The configuration phase is where most slowness lives. It runs before any task, for every build.
- The task graph connects tasks by inputs and outputs. Up-to-date checks and the build cache both depend on those declarations being honest.
- The configuration cache skips the configuration phase entirely when its inputs have not changed.
- Variants are the cross product of build types and flavours. They multiply tasks, not builds.
- If two modules have the same code, Gradle still configures both. Modularisation is not free at configuration time.
Mental model#
Think of Gradle as a theatre company preparing a performance.
Initialisation is the company deciding which theatres it is performing in tonight — one project, or three with a shared parent. Configuration is the rehearsal: every actor reads the whole play, every prop is checked, every scene is scheduled. It happens even if the play is a one-minute commercial. Execution is the performance itself: only the scenes that are needed run, and only if something changed since last time.
The build cache is a warehouse of previously performed scenes. If a scene's script and props are identical to a recorded performance, the recording is reused without rehearsing it.
Where the analogy breaks#
A theatre company rehearses once and performs many times. Gradle reconfigures on every invocation unless you enable the configuration cache — and that is the single biggest source of surprise about build times. A build can take 40 seconds and produce no output because it spent all 40 in configuration, and nothing in the output says so unless you ask.
The big picture#
Text description of this diagram
A grid of nine build variants: three build types (debug, release, staging) down the side, and three product flavours (free, paid, demo) across the top. Free-debug, paid-debug, demo-debug and so on. Selecting a cell shows the variant name, the APK it produces, whether R8 runs, and the four source sets merged to build it: main, the flavour, the build type, and the variant-specific source set, in increasing priority.
Click any cell. The four source sets are shown in merge order, and that order is the whole variant
system: a free flavour resource overrides a main resource, and a freeDebug resource overrides
both.
Deep dive#
The three phases#
Initialisation reads settings.gradle.kts, decides which projects are in the build, and creates
a Project object for each. Nothing about Android exists yet.
Configuration runs every project's build script. This is where android { ... } blocks execute,
where dependencies are declared, and where tasks are registered. It runs for tasks you did not ask
for, because Gradle cannot know what to run until every project has said what it can do.
Execution runs the requested tasks, in dependency order, skipping any whose inputs and outputs are unchanged. The three-phase model is documented in Gradle's own build lifecycle guide.1
# Configuration only, no execution
./gradlew :app:tasks
# Execution, with timing per phase
./gradlew :app:assembleDebug --profileThe --profile flag writes an HTML report that separates configuration time from execution time.
That report is the first thing to look at when a build "feels slow": if configuration dominates, no
amount of --parallel will help.
Why configuration is expensive#
Every android { } block does real work: it resolves the SDK, reads the manifest, discovers
variants and registers hundreds of tasks. Multiply by every module, and a fifty-module project can
spend a minute before it compiles anything.
The classic anti-pattern is work in the configuration phase that should happen in a task:
// Bad: runs on every configuration, for every build
val versionFile = file("version.txt")
val version = versionFile.readText().trim()
android {
defaultConfig { versionName = version }
}This reads a file during configuration. It is not cacheable, it is not tracked, and it runs even for
./gradlew tasks. The fix is to make it an input to a task, or to accept the cost and declare it —
but the first step is to notice it.
Inputs, outputs, and up-to-date checks#
A task is skipped when its declared inputs are unchanged and its declared outputs still exist. That is all. There is no magic.1
abstract class GenerateBuildInfo : DefaultTask() {
@get:Input abstract val versionName: Property<String>
@get:Input abstract val gitSha: Property<String>
@get:OutputFile abstract val output: RegularFileProperty
@TaskAction
fun generate() {
output.get().asFile.writeText(
"""object BuildInfo {
const val VERSION = "${versionName.get()}"
const val SHA = "${gitSha.get()}"
}"""
)
}
}Declaring gitSha as an @Input means a new commit invalidates the task. Forgetting to declare it
means the task is considered up to date and the app ships with a stale SHA — a bug that is very hard
to connect to its cause.
The build cache and the configuration cache#
They sound similar and solve different problems.
| Build cache | Configuration cache | |
|---|---|---|
| Caches | Task outputs, keyed by inputs | The configured task graph |
| Skips | Execution of unchanged tasks | The entire configuration phase |
| Invalidated by | Any input change | Any build script or settings change |
| Shared across machines | Yes, with a remote cache | No |
A remote build cache is what makes CI fast: one machine compiles a module, every subsequent build on any machine with the same inputs restores the output.2 The configuration cache is what makes local incremental builds feel instant.
Both require the build to be honest about inputs. That is why enabling either one on an old project often surfaces latent bugs — which is annoying, and also the point.
Variants multiply tasks, not processes#
The variant matrix above has nine cells. Gradle does not run nine separate builds; it builds one
graph with shared nodes. compileReleaseKotlin is shared by every release variant that has the same
sources; mergeFreeDebugResources is unique to one.
This is also why flavour count has a superlinear cost. Each flavour adds source sets, and each variant adds a manifest merge and a resource merge. Two flavours and two build types is four variants and manageable. Five flavours and three build types is fifteen, and every one of them configures.
Why modularisation is not a free lunch#
A common belief: "split the app into modules and builds get faster." The execution phase does get better — unchanged modules are not recompiled. But the configuration phase gets worse, because every module has a build script that must run.
The net effect depends on the project. For a large app with a long compile, modularisation wins. For
a small app, it can lose. The honest answer is to measure both phases before and after, which
--profile makes possible.
The build as a program you maintain#
A Gradle build script is code, and it accumulates the same problems as any other code: duplication, hidden coupling, and logic that seemed reasonable at the time.
Two patterns show up in almost every mature project.
Convention plugins. A build-logic included build defines plugins that configure a module the
way the project expects — the same compile SDK, the same Kotlin options, the same lint config. A
module then applies one plugin instead of repeating thirty lines. The win is not brevity; it is that
changing a convention changes every module at once.
Build logic in tasks, not in scripts. Reading a file, computing a version, generating source — all of these belong in a task with declared inputs and outputs, where Gradle can cache them and where the configuration phase does not pay for them.
The smell to watch for is a build script that gets slower as the project grows. That is almost always work in the configuration phase that should have been a task.
A checklist for a slow build#
In order, because each step is cheaper than the next.
- Run
--profileand read whether configuration or execution dominates. - If configuration dominates, enable the configuration cache and fix what breaks.
- If execution dominates, enable the build cache and a remote cache for CI.
- Look for annotation processors: kapt is the single most expensive common task.
- Check for a
buildSrcchange invalidating everything; considerbuild-logicinstead. - Only then consider hardware or parallelism.
Most teams stop at step one and guess. The profile report is free and answers the question directly.
How it evolved#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 4 | 1.6 Donut |
|
| 5 | 2.0 Eclair |
|
| 8 | 2.2 Froyo |
|
| 9 | 2.3 Gingerbread |
|
| 11 | 3.0 Honeycomb |
|
| 13 | 3.2 Honeycomb |
|
| 14 | 4.0 Ice Cream Sandwich |
|
| 16 | 4.1 Jelly Bean |
|
| 17 | 4.2 Jelly Bean |
|
| 18 | 4.3 Jelly Bean |
|
| 19 | 4.4 KitKat |
|
| 20 | 4.4W KitKat (Wear) |
|
| 21 | 5.0 Lollipop |
|
| 22 | 5.1 Lollipop |
|
| 23 | 6.0 Marshmallow |
|
| 24 | 7.0 Nougat |
|
| 26 | 8.0 Oreo |
|
| 27 | 8.1 Oreo |
|
| 28 | 9 Pie |
|
| 29 | 10 |
|
| 30 | 11 |
|
| 31 | 12 |
|
| 32 | 12L |
|
| 33 | 13 Tiramisu |
|
| 34 | 14 Upside Down Cake |
|
| 35 | 15 Vanilla Ice Cream |
|
| 36 | 16 Baklava |
|
| 37 | 17 Cinnamon Bun |
|
Under the hood#
- settings.gradle.ktswhich projects are in the build; the first thing read
- build.gradle.ktsroot project configuration, usually just plugin versions
- gradle.propertiesbuild-wide flags such as the configuration cache and JVM args
- libs.versions.tomlthe version catalog, read during configuration
- the HTML timing report produced by --profile
Curiosity corner#
--dry-runstill configures. It prints the task graph, which means it pays the full configuration cost. A dry run that takes a minute is telling you something.- Gradle's daemon is why the second build is faster than the first. A JVM is kept warm between
invocations;
./gradlew --stopthrows that away. cleanis usually the wrong fix. It deletes the build directory, which is the cache. If a clean build works and an incremental one does not, the bug is an undeclared input.
Try it#
./gradlew :app:assembleDebug --profile
Example output
See build/reports/profile/profile-*.html./gradlew :app:assembleDebug --info | grep -A2 "Task :app:compileDebugKotlin"
Example output
> Task :app:compileDebugKotlin
Input property 'source' file changed: app/src/main/java/.../Main.kt./gradlew :app:assembleDebug --configuration-cache
Example output
Configuration cache entry stored.
BUILD SUCCESSFUL in 4sBuild it#
Add org.gradle.configuration-cache=true to gradle.properties on a project you own and run the
build twice. The difference between the first and second run is the configuration phase
disappearing.
Check yourself#
Check yourself
Put these build stages in order, from first to last.
Drag, or use the arrows to reorder.
- 1apksigner writes the v2/v3 signature blocks
- 2kotlinc compiles Kotlin to JVM class files
- 3AAPT2 link produces resources.arsc and the R class
- 4D8 converts class files to DEX
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsGradle user manualdocs.gradle.orgaccessed 2026-10-27↩
- docsOptimize your build speeddeveloper.android.comaccessed 2026-10-27↩
- docsConfigure your builddeveloper.android.comaccessed 2026-10-27↩
- docsJetpackdeveloper.android.comaccessed 2026-10-27↩