Gradle build cache deep dive: how we cut 70% of redundant KMP compilation
Meta description: Learn how Gradle’s content-addressable build cache works at the hash level and how a remote cache setup eliminated 70% of redundant compilation across KMP modules.
Tags: kotlin, kmp, multiplatform, devops, cicd
TL;DR
Gradle’s build cache uses content-addressable hashing of task inputs to skip redundant work, but KMP modules introduce relocatability pitfalls with expect/actual declarations, annotation processors, and compiler flag drift. By deploying a self-hosted S3-compatible remote cache, normalizing non-deterministic inputs, and eliminating timestamp poisoning in BuildConfig, we reduced total CI compilation time by 70% across 48 KMP modules. This post covers how the hashing works, what breaks it, and how to fix it.
How Gradle’s build cache actually works
Every cacheable Gradle task produces a cache key, a SHA-256 hash computed from the task’s declared inputs. This is content-addressable storage: the hash is derived from what goes in, not when or where it runs.
The fingerprinting process hashes these components in order:
- Task implementation class: the fully qualified class name and classloader hash
- Task action implementations: bytecode hashes of any registered actions
- Input properties: each
@Input,@InputFile,@InputDirectoryvalue, normalized and hashed - Classpath inputs:
@Classpathannotated inputs use ABI-aware hashing (method signatures, not debug info)
For file inputs, Gradle computes hashes using the file content and the path sensitivity setting. This is where most teams start leaking cache misses without realizing it.
Path sensitivity modes
| Mode | What gets hashed | Use case |
|---|---|---|
ABSOLUTE | Full absolute path + content | Almost never correct for caching |
RELATIVE | Path relative to root + content | Default for most inputs |
NAME_ONLY | Filename + content | Resources, assets |
NONE | Content only | Order-independent file collections |
What most teams get wrong: the default RELATIVE sensitivity means that if your project lives at /Users/alice/dev/app locally but /home/runner/work/app on CI, the cache keys will differ for any task that hasn’t explicitly declared relocatability. You get zero cache sharing between local and CI despite identical source code.
KMP-specific relocatability pitfalls
Kotlin Multiplatform complicates caching because expect/actual declarations create cross-module input dependencies that Gradle must track. The KotlinCompile task for a shared module depends on the compiled output of platform-specific actual implementations, and those outputs carry path information.
In my experience building production KMP systems, three issues account for most cache misses:
1. Kotlin compiler flag drift between IDE and CLI
IntelliJ injects compiler arguments that differ from your build.gradle.kts configuration. The most common offender:
// IDE silently adds this; CLI does not
-Xuse-k2=true
-Xjvm-default=all
Since compiler arguments are task inputs, this silently produces different cache keys. The fix: explicitly lock all compiler flags in your build script.
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
freeCompilerArgs.addAll("-Xjvm-default=all")
}
}
2. BuildConfig timestamp poisoning
If your BuildConfig includes a build timestamp, every single build produces a unique artifact. Anything downstream of that module recompiles.
// NEVER do this in a cached build
buildConfigField("String", "BUILD_TIME", "\"${System.currentTimeMillis()}\"")
Replace it with a Git commit hash or inject timestamps only in release builds.
3. Annotation processor non-determinism
Certain annotation processors (Dagger/Hilt, Room) generate code with non-deterministic ordering. HashMap iteration order leaks into generated source files. The content changes between runs, invalidating the cache.
Before we addressed these three issues, our cache hit rate on CI sat at 23%. After: 87%.
Remote cache setup: S3-compatible object storage
You do not need Gradle Enterprise for a remote cache. Any S3-compatible backend works. We use MinIO on a single VM, total cost under $20/month.
// settings.gradle.kts
buildCache {
local { isEnabled = true }
remote<HttpBuildCache> {
url = uri("https://cache.internal.dev/cache/")
isPush = System.getenv("CI") == "true"
credentials {
username = System.getenv("CACHE_USER") ?: ""
password = System.getenv("CACHE_PASS") ?: ""
}
}
}
Only CI should push to the remote cache. Local machines pull only. If you let developer machines push, you’ll poison the cache with environment-specific artifacts and spend a week figuring out why hit rates tanked.
Before/after build scan data
Across our 48 KMP modules (shared, Android, iOS, desktop targets), here are the results after deploying remote caching with input normalization:
| Metric | Before | After | Change |
|---|---|---|---|
| Full CI build (clean) | 14m 32s | 4m 18s | -70.4% |
| Incremental CI build | 8m 45s | 2m 12s | -74.8% |
| Cache hit rate (CI) | 23% | 87% | +64pp |
| Cache hit rate (local) | 0% | 72% | +72pp |
| Cache storage (30-day) | N/A | 4.2 GB | — |
The local hit rate going from zero to 72% was honestly the biggest win. Engineers pulling main and building immediately get most artifacts from the remote cache instead of recompiling everything. The CI time savings are nice, but that feeling of git pull && ./gradlew build finishing fast is what actually changed how the team worked day to day.
Debugging cache misses
When a task misses the cache unexpectedly, use the build scan comparison or run:
./gradlew :shared:compileKotlinJvm --build-cache -Dorg.gradle.caching.debug=true
This logs every input that contributes to the cache key. Diff two runs to find the divergent input. Nine times out of ten, it’s an absolute path, a timestamp, or a compiler flag you didn’t know was being injected.
What to do with all this
Audit your task inputs with caching.debug=true. Absolute paths, timestamps, and non-deterministic annotation processors are the top three cache killers in KMP projects. Fix them before deploying a remote cache or you’re just caching misses faster.
Deploy a self-hosted S3-compatible remote cache with CI-only push. MinIO or any compatible backend eliminates the need for Gradle Enterprise while giving you full cache sharing between CI runs and local builds.
Lock every Kotlin compiler flag explicitly in your build script. IDE-injected arguments are invisible cache key mutations. If it’s not declared in build.gradle.kts, it will eventually diverge and silently wreck your hit rate.