---
title: "Gradle Build Cache Deep Dive: How We Cut 70% of Redundant KMP Compilation"
published: true
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, devops, android, performance
canonical_url: https://blog.mvpfactory.co/gradle-build-cache-deep-dive-kmp
---
## What you will build
By the end of this tutorial, you will understand exactly how Gradle computes cache keys from task inputs, why KMP modules break caching in non-obvious ways, and how to deploy a self-hosted remote cache that shares artifacts between CI and local builds. We took our CI build from 14m 32s down to 4m 18s — a 70.4% reduction across 48 KMP modules. Let me show you how.
## Prerequisites
- A Kotlin Multiplatform project with at least one shared module
- Gradle 8.x with build cache enabled
- Basic familiarity with `settings.gradle.kts` and `build.gradle.kts`
- Access to an S3-compatible object store (MinIO, AWS S3, or similar)
## Step 1: Understand how Gradle fingerprints task inputs
Every cacheable Gradle task produces a SHA-256 cache key computed from its declared inputs. This is content-addressable storage — the hash comes from *what* goes in, not *when* or *where* it runs.
Gradle hashes these components in order:
1. **Task implementation class** — fully qualified class name and classloader hash
2. **Task action implementations** — bytecode hashes of registered actions
3. **Input properties** — each `@Input`, `@InputFile`, `@InputDirectory` value, normalized and hashed
4. **Classpath inputs** — `@Classpath` inputs use ABI-aware hashing (method signatures, not debug info)
For file inputs, Gradle hashes content plus path information based on the sensitivity mode:
| 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 |
Here is the gotcha that will save you hours: the default `RELATIVE` sensitivity means that if your project lives at `/Users/alice/dev/app` locally but `/home/runner/work/app` on CI, cache keys differ for any task that hasn't explicitly declared relocatability. You get zero cache sharing despite identical source code.
## Step 2: Fix KMP-specific cache killers
Kotlin Multiplatform complicates caching because `expect`/`actual` declarations create cross-module input dependencies that carry path information. In my experience building production KMP systems, three issues account for most cache misses.
**Lock your Kotlin compiler flags.** IntelliJ injects arguments that differ from your build script. Since compiler arguments are task inputs, this silently produces different cache keys.
kotlin
tasks.withType().configureEach {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
freeCompilerArgs.addAll("-Xjvm-default=all")
}
}
**Kill BuildConfig timestamps.** If your `BuildConfig` includes a build timestamp, every build produces a unique artifact. Everything downstream recompiles.
kotlin
// 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.
**Watch for annotation processor non-determinism.** Processors like Dagger/Hilt and Room generate code with non-deterministic ordering. HashMap iteration order leaks into generated files, invalidating the cache between runs.
Before we addressed these three issues, our cache hit rate sat at 23%. After: 87%.
## Step 3: Deploy a remote cache with S3-compatible storage
Here is the minimal setup to get this working. You do not need Gradle Enterprise — any S3-compatible backend works. We use MinIO on a single VM, total cost under $20/month.
kotlin
// settings.gradle.kts
buildCache {
local { isEnabled = true }
remote {
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 pushes to the remote cache. Local machines pull only. The docs do not mention this, but if you let developer machines push, you will poison the cache with environment-specific artifacts and spend a week figuring out why hit rates tanked.
## Step 4: Verify with build scan data
Here are our results across 48 KMP modules (shared, Android, iOS, desktop targets):
| 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 | — |
Debug unexpected misses with:
bash
./gradlew :shared:compileKotlinJvm --build-cache -Dorg.gradle.caching.debug=true
This logs every input contributing to the cache key. Diff two runs to find the divergent input. Nine times out of ten, it is an absolute path, a timestamp, or a compiler flag you didn't know was being injected.
## Gotchas
- **IDE compiler flag drift is invisible.** IntelliJ silently adds flags like `-Xuse-k2=true` and `-Xjvm-default=all`. If it is not declared in `build.gradle.kts`, it will eventually diverge and wreck your hit rate.
- **`RELATIVE` path sensitivity is not relocatable by default.** Different checkout paths between CI and local mean different cache keys for identical source code.
- **Annotation processor HashMap ordering** changes between runs. Same inputs, different generated output, zero cache hits.
- **One timestamp field poisons everything downstream.** A `BuildConfig` with `System.currentTimeMillis()` forces recompilation of every module that depends on it.
## Wrapping up
Audit your task inputs with `caching.debug=true` before deploying a remote cache — otherwise you are just caching misses faster. Lock every Kotlin compiler flag explicitly, kill non-deterministic inputs, and let CI be the only cache publisher. The local hit rate going from zero to 72% was honestly the biggest win. That feeling of `git pull && ./gradlew build` finishing fast is what actually changed how the team worked day to day.
Top comments (0)