Gradle Build Cache Deep Dive
--- 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 https://your-cache-node.example.com/cache/") 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 **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 **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 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.
