---
title: "Gradle Build Cache Deep Dive: How We Cut KMP CI Times by 65%"
published: true
description: "A hands-on walkthrough of Gradle's content-addressable build cache, remote cache setup, and the five KMP-specific fixes that dropped our CI from 23 to 8 minutes."
tags: kotlin, android, devops, performance
canonical_url: https://blog.mvpfactory.co/gradle-build-cache-deep-dive-kmp-ci-times
---
## What You Will Build
By the end of this tutorial, you will have a properly configured Gradle remote build cache for a Kotlin Multiplatform project — and you will know how to debug the five specific cache invalidation bugs that silently destroy your hit rates. We took a 47-module KMP project from a 34% cache hit rate to 87%, cutting PR check times from 16 minutes down to under 6. Let me show you exactly how.
## Prerequisites
- A Kotlin Multiplatform project with at least a few modules (the more modules, the bigger the payoff)
- Gradle 8.x+ with the `com.gradle.build-cache` plugin
- A GCS bucket or S3 bucket for remote cache storage
- Access to Gradle Build Scans (free for open-source, paid for private projects)
## Step 1: Understand What Gradle Is Actually Hashing
Every cacheable task produces a cache key — a hash of the task's class, its input properties, and input file contents. This is content-addressable storage: the key is based on actual content, not file paths or timestamps.
The lookup flow works like this: Gradle computes the key before execution, checks the local cache (`~/.gradle/caches/build-cache-1/`), then checks the remote cache on miss. On hit, outputs are unpacked and the task is skipped entirely.
Here is the gotcha that will save you hours: a single non-deterministic input poisons the entire key. One absolute path, one timestamp, one build-machine hostname — and your cache hit rate collapses.
## Step 2: Configure Remote Cache
Here is the minimal setup to get this working in `settings.gradle.kts`:
kotlin
buildCache {
local { isEnabled = true }
remote {
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 single rule prevents developer laptops from polluting the shared cache with environment-specific artifacts. We evaluated GCS vs S3 over a two-week A/B test with 12 engineers: GCS averaged 45ms read / 78ms write latency versus S3's 62ms / 91ms. Both cost under $2.50/month for ~80GB. We went with GCS because our CI was already on Google Cloud and the latency difference compounds across hundreds of tasks.
## Step 3: Fix the Five KMP-Specific Cache Killers
This is where most KMP teams get burned. We found these using `-Dorg.gradle.caching.debug=true` and Gradle Build Scans.
**1. Cinterop tasks are non-cacheable by default.** The generated `.def` file paths are absolute, breaking relocatability. Pin inputs explicitly:
kotlin
tasks.withType() {
inputs.files(project.file("src/nativeInterop/cinterop/"))
.withPathSensitivity(PathSensitivity.RELATIVE)
}
**2. Expect/actual resolution triggers full recompilation.** The docs do not mention this, but changing an `actual` can invalidate caches for unrelated common modules due to how the Kotlin compiler tracks dependencies. Isolate expect/actual contracts in a dedicated `:core:contract` module with minimal dependencies.
**3. Kotlin/Native compiler version leaks into cache keys.** If CI agents run different Kotlin versions, you get constant misses. Pin it in `gradle.properties`:
properties
kotlin.version=2.1.0
kotlin.native.cacheKind.iosArm64=none
**4. Resource bundling embeds absolute paths.** Tasks like `copyResourcesForIos` break relocatability across machines. Use `@PathSensitive(PathSensitivity.RELATIVE)` annotations on custom resource-copying tasks.
**5. BuildConfig fields with timestamps.** One `buildConfigField("String", "BUILD_TIME", ...)` invalidates half your task graph — both Android and shared modules. Move dynamic values to runtime resolution.
## Step 4: Debug Cache Misses
Let me show you a pattern I use in every project. Run this and compare outputs across two machines:
bash
./gradlew :shared:compileKotlinIosArm64 \
--build-cache \
-Dorg.gradle.caching.debug=true 2>&1 | grep "Cache key"
The first divergence is your culprit. For a richer view, run with `--scan` and check the timeline for tasks marked "executed" that should have been "from cache." The input hash breakdown shows you exactly which input changed.
## Real Results
After fixing all five issues on our 47-module project:
| Metric | Before | After | Change |
|---|---|---|---|
| PR check (avg) | 16m 22s | 5m 41s | **65% faster** |
| Incremental CI | 18m 40s | 8m 05s | **57% faster** |
| Cache hit rate | 34% | 87% | **+53pp** |
| Tasks skipped | 112/329 | 286/329 | **+174 tasks** |
Shaving 10 minutes off every PR check changes how a team works. Those 16-minute waits had turned into motionless staring sessions — I genuinely relied on [HealthyDesk](https://play.google.com/store/apps/details?id=com.healthydesk) to remind me to stand up and stretch while builds ran.
## Gotchas
- **Clean builds barely improve** (~2%). The gains are entirely in incremental and PR builds — the feedback loops your team feels daily.
- **Cache poisoning from local machines** is the number one silent killer. Only let CI push to remote cache. Always.
- **Treat cache keys like API contracts.** Any task input change is a breaking change. Add cache-hit-rate monitoring to your CI dashboard and alert when it drops below 70%.
## Wrapping Up
If your KMP cache hit rate is below 70%, you have configuration bugs, not a tooling problem. Run a Build Scan on CI today, fix the five issues above, and monitor the hit rate weekly. Gradle's build cache is the highest-leverage optimization for KMP CI pipelines — but only once you eliminate the silent invalidation bugs that KMP introduces. For us, that meant 10 minutes back on every push. Worth every hour we spent debugging it.
























