Gradle's Configuration Cache: Earning the Fast Path
The configuration cache removes the 10-30 seconds every build spends deciding what to build. Why it's different from the build cache, the four rules it enforces, the provider APIs that fix your build logic, and a migration order that doesn't stall.
Hessam Rastegari
AI Native Engineer · 12 years shipping Android
TL;DR — The build cache stores task outputs; the configuration cache stores the task graph.
The second one fires on every build, including incremental ones where nothing else was cacheable — worth
10–30 seconds per invocation on a large multi-module app. Enabling it is one property; making it work means
obeying four rules your build logic probably breaks: no environment reads at configuration time, no Project
access during execution, no shared mutable state, no unserialisable task state. The fixes are the provider
APIs (providers.exec, providers.environmentVariable, layout.buildDirectory). Migrate with
problems=warn, fix your own convention plugins first, and expect a third-party plugin to be the real
blocker.
Two caches people conflate
Build cache — "this compilation already happened somewhere, reuse the output." Helps when inputs are unchanged. Contributes nothing when you actually changed code.
Configuration cache — "I already computed the task graph for this invocation; skip the configuration phase entirely." Helps on every build, because Gradle configures the whole project every time before it runs anything.
On a 60-module Android project, configuration is typically 10–30 seconds. That's paid on every
assembleDebug, every test, every IDE sync-triggered build — whether or not you changed a single line.
Removing it is a larger day-to-day win than most people expect, precisely because it's the part that never
benefited from anything else.
# gradle.properties
org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn # while migrating
Reusing configuration cache.
BUILD SUCCESSFUL in 4s
That line — Reusing configuration cache — is the one to look for.
The four rules
The cache works by serialising the task graph after configuration and replaying it. That imposes real constraints, and they're where the work is.
1. No environment or system reads at configuration time
If your build script reads a value at configuration time, the cached graph would freeze that value forever.
// ❌ read once, baked into the cache
val isCi = System.getenv("CI") == "true"
// ✅ declared as an input; Gradle tracks it and
// invalidates the cache when it changes
val isCi = providers.environmentVariable("CI")
.map { it == "true" }
.orElse(false)
Same for System.getProperty, reading files, and — the big one — running processes.
2. No Project access at execution time
// ❌ Task.project inside a task action
abstract class GenerateTask : DefaultTask() {
@TaskAction
fun run() {
val dir = project.buildDir // not serialisable
project.exec { commandLine("git", "…") }
}
}
// ✅ inject what you need; capture values, not the Project
abstract class GenerateTask : DefaultTask() {
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@get:Input
abstract val gitSha: Property<String>
@get:Inject
abstract val execOps: ExecOperations
@TaskAction
fun run() { /* uses only the properties above */ }
}
@get:Inject for Gradle services (ExecOperations, FileSystemOperations, ArchiveOperations) is the
standard replacement for reaching through project.
3. No shared mutable state between tasks
A var in a plugin that one task writes and another reads doesn't survive serialisation. Pass data through
task properties, or a BuildService if it genuinely needs to be shared.
4. Everything in a task must be serialisable
Anything captured by a task — including in a lambda — gets serialised. Capturing a Project, a
Configuration, or a live Logger fails. The error tells you which field, which makes this the easiest rule
to satisfy in practice.
The canonical offender
Almost every Android build has some version of this, and it's usually the first thing the cache complains about:
// ❌ Runs git at CONFIGURATION time — on every build,
// before anything is compiled, and it poisons the cache.
val gitSha = "git rev-parse --short HEAD".runCommand()
android {
defaultConfig {
buildConfigField("String", "GIT_SHA", "\"$gitSha\"")
}
}
// ✅ A provider. Lazy, declared, cacheable.
val gitSha: Provider<String> = providers.exec {
commandLine("git", "rev-parse", "--short", "HEAD")
}.standardOutput.asText.map { it.trim() }
android {
defaultConfig {
buildConfigField("String", "GIT_SHA", "\"${gitSha.get()}\"")
}
}
The wider substitution table:
| Instead of | Use |
|---|---|
System.getenv("X") |
providers.environmentVariable("X") |
System.getProperty("x") |
providers.systemProperty("x") |
"cmd".runCommand() |
providers.exec { commandLine(…) } |
project.buildDir |
layout.buildDirectory |
project.file("x") |
layout.projectDirectory.file("x") |
project.exec {} in a task |
injected ExecOperations |
File fields on a task |
RegularFileProperty / DirectoryProperty |
A migration order that doesn't stall
- Turn it on with
problems=warn. The build still succeeds; you get a report. Don't start withproblems=fail— you'll be blocked on someone else's plugin on day one and give up. - Run it and open the HTML report. Gradle writes a file at
build/reports/configuration-cache/…/configuration-cache-report.htmlthat names the exact file and line for each problem. This is what makes the migration tractable — it isn't detective work. - Fix your own build logic first. Your convention plugins and custom tasks. This is the majority of the problems and it's entirely within your control.
- Then triage third-party plugins. Check for a compatible version; most major Android plugins support it now. A plugin you can't fix is the usual reason a migration stalls, so find out early — a compatible release may exist, or the plugin may be one you can drop.
- Flip to
problems=failin CI once you're clean, so nobody regresses it. That step matters: without a gate, the first well-meaningSystem.getenvputs you back where you started and nobody notices for a month.
Verify with two consecutive builds:
./gradlew assembleDebug # "Configuration cache entry stored"
./gradlew assembleDebug # "Configuration cache entry reused"
Things that legitimately invalidate it
Not bugs — correct behaviour, worth knowing so you don't chase them:
- Changing any build file,
gradle.properties, or abuildSrc/build-logic source file. - A different set of requested tasks (
assembleDebugandtestDebugUnitTesthave separate entries). - A change in any value you declared via
providers— that's the whole point of declaring them. - A different Gradle or JDK version.
Editing application source code does not invalidate it, which is the case that matters and the reason this pays off all day.
Why the constraints are the actual product
It's tempting to read the rules as arbitrary restrictions imposed by a caching feature. They aren't.
Every one of them — declared inputs, no ambient state, no cross-task mutation, serialisable task state — is also a precondition for a build that is correct, reproducible, and safely parallel. A build that reads an environment variable at configuration time and bakes it into a task was already producing inconsistent results; you just hadn't been bitten yet.
The configuration cache is the first thing that refuses to let you pretend otherwise. Combined with convention plugins and the build cache — both covered earlier in this series — it's where the large, compounding build-time wins actually live.
The rule
The configuration cache isn't a flag you flip; it's a constraint you design your build logic for. Enable
it with problems=warn, read the report, replace ambient reads with providers and Project access with
injected services, and gate it in CI once you're green. Your build was already wrong in the places it
complains about — this just tells you where.