8 min read

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.

GradleAndroidBuild PerformanceCI/CDKotlin
HR

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

  1. Turn it on with problems=warn. The build still succeeds; you get a report. Don't start with problems=fail — you'll be blocked on someone else's plugin on day one and give up.
  2. Run it and open the HTML report. Gradle writes a file at build/reports/configuration-cache/…/configuration-cache-report.html that names the exact file and line for each problem. This is what makes the migration tractable — it isn't detective work.
  3. 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.
  4. 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.
  5. Flip to problems=fail in CI once you're clean, so nobody regresses it. That step matters: without a gate, the first well-meaning System.getenv puts 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 a buildSrc/build-logic source file.
  • A different set of requested tasks (assembleDebug and testDebugUnitTest have 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.

Keep reading