---
title: "Gradle at Scale: How We Cut KMP CI from 45 to 12 Minutes"
published: true
description: "A step-by-step workshop on configuration cache, remote build cache, and composite builds — the three Gradle optimizations that slashed our Kotlin Multiplatform CI by 73%."
tags: kotlin, android, devops, performance
canonical_url: https://blog.mvpfactory.co/gradle-at-scale-how-we-cut-kmp-ci-from-45-to-12-minutes
---
## What We Will Build
By the end of this tutorial, you will have three Gradle optimizations applied to your Kotlin Multiplatform project: configuration cache for instant task graph reuse, remote build cache for team-wide cache hits, and composite builds that stop convention plugin changes from nuking your entire dependency graph.
I use this exact setup on a 58-module KMP project. It took our CI from 45 minutes to 12. Here is the minimal setup to get this working.
## Prerequisites
- A multi-module Kotlin project (KMP or otherwise) using Gradle 8.1+
- Access to your `gradle.properties` and `settings.gradle.kts`
- Optional: a Develocity (Gradle Enterprise) instance for remote caching
## Step 1 — Enable Configuration Cache
Configuration cache serializes the entire task graph so Gradle skips configuration on subsequent runs. In our project, configuration alone ate 8–11 minutes.
Add these three lines to `gradle.properties`:
kotlin
// gradle.properties
org.gradle.configuration-cache=true
org.gradle.configuration-cache.max-problems=0
org.gradle.configuration-cache.parallel=true
Let me show you a pattern I use in every project: setting `max-problems=0` from day one. This forces strict compliance. Any plugin or script that breaks configuration cache fails immediately instead of silently degrading. You will spend time fixing violations — primarily custom tasks that capture `Project` instances at execution time — but the payoff is permanent.
**Result:** Configuration phase drops from ~10 minutes to under 5 seconds on cache hits.
## Step 2 — Set Up Remote Build Cache
Local cache helps you. Remote cache helps your team. When CI pushes build outputs to a shared cache, every subsequent build — on any machine — can reuse them.
kotlin
// settings.gradle.kts
buildCache {
local { isEnabled = true }
remote {
url = uri(providers.gradleProperty("cacheUrl").get())
isPush = System.getenv("CI") != null
credentials {
username = providers.gradleProperty("cacheUser").get()
password = providers.gradleProperty("cachePass").get()
}
}
}
The thing that actually moved the needle was normalizing file paths. KMP generates platform-specific tasks (`compileKotlinJvm`, `compileKotlinIosArm64`), and absolute paths in compiler arguments were invalidating caches across machines. We fixed this by making all `$rootDir` references relative:
kotlin
// In custom task inputs, replace absolute paths:
val relativePath = projectDir.asFile.relativeTo(rootDir)
This single change pushed our cache hit rate from 52% to 78%. Run `./gradlew build --scan` to identify which inputs are breaking your cache keys — the build scan comparison tool in Develocity surfaces these in minutes.
## Step 3 — Migrate buildSrc to Composite Builds
If you do one thing from this tutorial, make it this. Most teams put convention plugins in `buildSrc`, which means any change to shared build logic invalidates every module. All 58 recompile from scratch.
Migrate to a composite `build-logic` include:
kotlin
// settings.gradle.kts
pluginManagement {
includeBuild("build-logic")
}
// build-logic/settings.gradle.kts
dependencyResolutionManagement {
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
This isolates plugin compilation from your main build graph. Changes to `build-logic` only recompile the plugins themselves — not all your modules.
## Final Numbers
| Metric | Before | After |
|---|---|---|
| Avg CI build time | 45 min | 12 min |
| Cache hit rate | 12% | 82% |
| Annual CI cost | ~$48,000 | ~$13,800 |
| Dev wait time (P95) | 38 min | 9 min |
## Gotchas
**Configuration cache violations are tedious.** Budget two weeks for a large project. The docs do not mention this, but the most common offender is custom tasks that reference `project.configurations` at execution time. Move those references into `Provider` APIs.
**Absolute paths silently destroy cache hits.** You will see 100% cache misses and have no idea why. Always run `--scan` after enabling remote cache and compare two builds from different machines.
**`buildSrc` and composite builds cannot coexist cleanly.** Delete `buildSrc` entirely before migrating to `build-logic`. A half-migration creates two configuration phases and makes things slower.
**`isPush` must be CI-only.** If developers push to remote cache from local machines with different JDKs or OS versions, you will pollute the cache with incompatible outputs.
## Conclusion
These three optimizations are multiplicative. Configuration cache gets Gradle to the task graph faster, which means it discovers remote cache hits sooner. Composite builds preserve those hits because plugin changes do not blow away the whole graph. Each fix makes the others more effective.
Here is the gotcha that will save you hours: start with composite builds first if your team changes `buildSrc` frequently. That alone will give you the most immediate relief while you work through configuration cache violations.
**Resources:**
- [Gradle Configuration Cache docs](https://docs.gradle.org/current/userguide/configuration_cache.html)
- [Gradle Build Cache docs](https://docs.gradle.org/current/userguide/build_cache.html)
- [Develocity Build Scans](https://gradle.com/develocity/)
Top comments (0)