Gradle Build Cache Deep Dive: Content-Addressable Storage, Remote Cache Invalidation, and the Configuration That Cut Our KMP CI Times by 65%
TL;DR
Gradle’s build cache uses content-addressable storage to skip redundant work, but KMP projects introduce cache invalidation pitfalls that silently destroy hit rates. Cinterop tasks, expect/actual resolution, non-relocatable paths. After debugging our cache with build scans and fixing five specific misconfigurations, we dropped CI times on a 47-module KMP project from 23 minutes to 8 minutes.
How Gradle’s build cache actually works
Every cacheable task in Gradle produces a cache key, a hash computed from the task’s class, its input properties, and input file contents. This is content-addressable storage: the key isn’t based on file paths or timestamps, but on the actual content flowing into the task.
The lookup flow:
- Gradle computes the cache key before execution
- It checks the local cache (
~/.gradle/caches/build-cache-1/) - On miss, it checks the remote cache (GCS, S3, or HTTP node)
- On hit, outputs are unpacked directly and the task is skipped entirely
The thing most teams don’t realize: a single non-deterministic input poisons the entire key. One absolute path, one timestamp, one build-machine hostname baked into an input property, and your cache hit rate collapses.
Remote cache setup: GCS vs S3
We evaluated both backends for our team of 12 engineers. Two-week A/B test results:
| Metric | GCS Backend | S3 Backend |
|---|---|---|
| Avg cache read latency | 45ms | 62ms |
| Avg cache write latency | 78ms | 91ms |
| Monthly cost (~80GB cache) | $1.84 | $2.10 |
| Setup complexity | Moderate (service account JSON) | Moderate (IAM roles) |
| Gradle plugin support | com.gradle.build-cache | com.gradle.build-cache |
Both work fine. We went with GCS because our CI was already on Google Cloud, and the latency difference compounds across hundreds of tasks.
The settings.gradle.kts configuration:
buildCache {
local { isEnabled = true }
remote<HttpBuildCache> {
url = uri("https://your-cache-node.example.com/cache/")
isPush = System.getenv("CI") != null // only CI pushes
isEnabled = true
}
}
Local machines pull, CI pushes. This prevents developer laptops from polluting the shared cache with environment-specific artifacts.
The five KMP-specific cache killers
This is where most KMP teams get burned. We found these exact issues using --build-cache with -Dorg.gradle.caching.debug=true and Gradle Build Scans.
1. Cinterop tasks are non-cacheable by default
cinteropKlibCommonize and platform-specific cinterop tasks don’t declare their inputs correctly. The generated .def file paths are absolute, which breaks relocatability.
Pin the .def files and declare inputs explicitly:
tasks.withType<CInteropProcess>() {
inputs.files(project.file("src/nativeInterop/cinterop/"))
.withPathSensitivity(PathSensitivity.RELATIVE)
}
2. Expect/actual resolution triggers full recompilation
When an expect declaration changes, every actual module recompiles. No surprise there. But the inverse also happens because of how the Kotlin compiler tracks dependencies: changing an actual can invalidate caches for unrelated common modules. This one stung.
Isolate expect/actual contracts in a dedicated :core:contract module with minimal dependencies.
3. Kotlin/Native compiler version in cache key
The K/N compiler embeds its version in task inputs. If you upgrade Kotlin on one CI agent while others lag behind, you get constant misses.
Pin Kotlin versions in gradle.properties and enforce it in CI:
kotlin.version=2.1.0
kotlin.native.cacheKind.iosArm64=none
4. Resource bundling breaks relocatability
KMP resource tasks (copyResourcesForIos) often embed absolute project paths. Two engineers building the same code on different machines get different cache keys. Maddening.
Use @PathSensitive(PathSensitivity.RELATIVE) annotations on custom resource-copying tasks.
5. Build config fields with timestamps
Classic problem, but it hits KMP harder because BuildConfig generation affects both Android and shared modules. One buildConfigField("String", "BUILD_TIME", ...) invalidates half your task graph.
Move dynamic values to runtime resolution or exclude them from release-branch builds.
Before and after: real CI metrics
After addressing all five issues on our 47-module KMP project (shared, Android, iOS, desktop targets):
| Metric | Before | After | Improvement |
|---|---|---|---|
| Full CI build (clean) | 23m 12s | 22m 48s | ~2% |
| Incremental CI build | 18m 40s | 8m 05s | 57% |
| PR check (avg) | 16m 22s | 5m 41s | 65% |
| Cache hit rate | 34% | 87% | +53pp |
| Avg tasks skipped | 112/329 | 286/329 | +174 tasks |
Clean builds barely budge, obviously. The incremental and PR builds are where it actually matters. Those are the feedback loops your team feels every day. Shaving 10 minutes off every PR check changes how people work. Before this optimization, our 16-minute PR cycles had turned into hunched, motionless staring sessions. I genuinely relied on HealthyDesk to remind me to stand up and stretch during those waits.
Debugging cache misses
The single most useful tool: Gradle Build Scans. Run any build with --scan, open the timeline, and look for tasks marked “executed” that should have been “from cache.” Click into the task to see the full input hash breakdown. It shows you exactly which input changed.
For local debugging:
./gradlew :shared:compileKotlinIosArm64 \
--build-cache \
-Dorg.gradle.caching.debug=true 2>&1 | grep "Cache key"
Compare outputs across two machines. The first divergence is your culprit.
What to do next
Audit your cache hit rate. Run a Build Scan on CI right now. If you’re below 70%, you have configuration bugs, not a tooling problem. The five KMP issues above cover most of what I’ve seen in practice.
Only let CI agents write to remote cache. Local machines read. This single rule prevents the majority of cache poisoning.
Treat cache keys like API contracts. Any task input change is a breaking change to your cache. Add cache-hit-rate monitoring to your CI dashboard and alert when it drops below threshold. We use a simple grep on Build Scan exports to track this weekly.
Gradle’s build cache is the highest-leverage optimization for KMP CI pipelines, but only if you eliminate the silent invalidation bugs that KMP introduces. Fix these five issues, monitor your hit rate, and your team gets real time back on every PR. For us, that was 10 minutes per push. Worth every hour we spent debugging it.