Zygote
The build systemintermediate

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.

12 minAPI 1–37 · 17interactivequizverified 2026-10-27

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#

Loading diagram…
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 --profile

The --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 cacheConfiguration cache
CachesTask outputs, keyed by inputsThe configured task graph
SkipsExecution of unchanged tasksThe entire configuration phase
Invalidated byAny input changeAny build script or settings change
Shared across machinesYes, with a remote cacheNo

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.

  1. Run --profile and read whether configuration or execution dominates.
  2. If configuration dominates, enable the configuration cache and fix what breaks.
  3. If execution dominates, enable the build cache and a remote cache for CI.
  4. Look for annotation processors: kapt is the single most expensive common task.
  5. Check for a buildSrc change invalidating everything; consider build-logic instead.
  6. 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#

Every build configures every project, then runs the requested tasks. Incremental execution helps; configuration is repeated each time.
Architecture changes29 releases
APIVersionWhat changed
11.0
  • Initial AOSP platform: Linux kernel, Dalvik VM, core libraries
  • The first zygote-based app spawning model
41.6 Donut
  • Support for different screen densities via resource qualifiers
52.0 Eclair
  • First multi-core-friendly scheduling and graphics work
82.2 Froyo
  • Trace-based JIT compiler added to Dalvik
  • Support for x86 and ARMv7 optimisations
92.3 Gingerbread
  • Concurrent garbage collector added to Dalvik (GC pause reduction)
113.0 Honeycomb
  • Hardware acceleration for the View system (opt-in per app)
133.2 Honeycomb
  • New screen-size resource qualifiers for compatibility scaling
144.0 Ice Cream Sandwich
  • One system image for phones and tablets
  • Hardware acceleration on by default
164.1 Jelly Bean
  • Project Butter: vsync, triple buffering, Choreographer
  • RenderThread architecture begins
174.2 Jelly Bean
  • Multi-user support on tablets
  • SELinux enforcement begins in the kernel (behind the scenes)
184.3 Jelly Bean
  • SELinux enforcement rolled out more broadly
  • Binder changes for restricted profiles
194.4 KitKat
  • Android Runtime (ART) appears as an experimental runtime
  • Kernel moved under a tighter vendor control
204.4W KitKat (Wear)
  • First time the platform shipped for a new form factor
215.0 Lollipop
  • ART becomes the only runtime: ahead-of-time compilation at install
  • 64-bit ARM and x86 support
  • RenderThread and OpenGL ES 3.1
  • Project Volta and JobScheduler
  • SELinux fully enforcing
225.1 Lollipop
  • Device protection ties verified boot to Google accounts
236.0 Marshmallow
  • Runtime permission model changes how every app asks for access
  • Doze batches background work
  • Fingerprint HAL
  • Adoptable storage
247.0 Nougat
  • Project Treble groundwork
  • A/B seamless updates
  • Hybrid JIT + AOT compilation in ART
  • MediaServer split into sandboxed processes
268.0 Oreo
  • Project Treble: the vendor interface separates vendor and system code
  • Background execution limits
  • Broadcast and service restrictions
  • HIDL for HALs
278.1 Oreo
  • Android Go: a lighter configuration of the same platform
289 Pie
  • App Standby Buckets
  • Mainline groundwork
  • Non-SDK interface restrictions (greylists) begin
  • BiometricPrompt
2910
  • Project Mainline: APEX and updatable system modules
  • Scoped storage
  • Bubbles
  • Project Mainline starts shipping through Play
3011
  • GKI: the Generic Kernel Image splits the kernel into core + vendor modules
  • App hibernation
  • Package visibility filtering
  • Incremental app installs
3112
  • Bluetooth permissions split into the nearby-devices family
  • Exact alarm permissions
  • Foreground service launch restrictions
3212L
  • Window size classes and activity embedding
3313 Tiramisu
  • Granular media permissions replace READ_EXTERNAL_STORAGE
  • The notification permission becomes a runtime permission
  • Predictive back gesture
3414 Upside Down Cake
  • Foreground service types are mandatory
  • Selected photos access via READ_MEDIA_VISUAL_USER_SELECTED
3515 Vanilla Ice Cream
  • 16 KB page size support for native libraries
  • Edge-to-edge is enforced when targeting 35
3616 Baklava
  • The release moves to Q2 (June) with a Q4 feature drop
  • Two SDK releases per year starting with 36
  • Desktop windowing arrives in developer options then stable
3717 Cinnamon Bun
  • Strict per-device app memory limits are enforced by the system
  • Generational garbage collection is added to ART's Concurrent Mark-Compact collector
  • A lock-free MessageQueue for apps targeting API 37
  • Compose-first: new platform and Jetpack APIs target Compose only; View libraries enter maintenance mode
  • Hybrid APK signing (v3.2) combines classical and ML-DSA post-quantum signatures
  • Native library dynamic code loading must be read-only

Under the hood#

Curiosity corner#

  • --dry-run still 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 --stop throws that away.
  • clean is 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#

Time the phases

./gradlew :app:assembleDebug --profile

Example output
See build/reports/profile/profile-*.html
Ask why a task ran

./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
Turn on the configuration cache

./gradlew :app:assembleDebug --configuration-cache

Example output
Configuration cache entry stored.
BUILD SUCCESSFUL in 4s

Build 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

1 / 12

Put these build stages in order, from first to last.

Drag, or use the arrows to reorder.

  1. 1apksigner writes the v2/v3 signature blocks
  2. 2kotlinc compiles Kotlin to JVM class files
  3. 3AAPT2 link produces resources.arsc and the R class
  4. 4D8 converts class files to DEX
0 answered · 0 correct

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.

  1. docsGradle user manualdocs.gradle.orgaccessed 2026-10-27↩
  2. docsOptimize your build speeddeveloper.android.comaccessed 2026-10-27↩
  3. docsConfigure your builddeveloper.android.comaccessed 2026-10-27↩
  4. docsJetpackdeveloper.android.comaccessed 2026-10-27↩