Gradle at scale: how we cut KMP CI from 45 to 12 min
Meta description: Learn how configuration cache, remote build cache, and composite builds slashed our Kotlin Multiplatform CI from 45 to 12 minutes with real metrics and Gradle settings.
Tags: kotlin, kmp, multiplatform, cicd, devops
TL;DR
Our 58-module KMP project took 45 minutes per CI run. By enabling configuration cache, optimizing remote build cache hits via Develocity, and migrating convention plugins to composite builds, we reduced that to 12 minutes. A 73% reduction. Annual CI cost dropped from ~$48K to ~$14K. This post covers exactly what we did, with the Gradle settings and metrics to back it up.

The problem nobody wants to fix
Gradle build performance is unsexy. It’s deeply technical, and most teams just accept slow builds as a cost of doing business.
That acceptance is expensive. Our team was bleeding 45 minutes per CI run across 120+ daily builds. Engineers waited. PRs stacked. Merge conflicts multiplied. The cost compounds fast, and it’s easy to miss because no single build feels like the problem.
Most teams get this wrong by optimizing tasks individually instead of attacking the three systemic bottlenecks: configuration time, cache misses, and dependency resolution overhead.
Phase 1: configuration cache
Configuration cache serializes the task graph so Gradle skips the entire configuration phase on subsequent runs. In a 58-module KMP project, configuration alone consumed 8-11 minutes.
// gradle.properties
org.gradle.configuration-cache=true
org.gradle.configuration-cache.max-problems=0
org.gradle.configuration-cache.parallel=true
The max-problems=0 setting matters more than it looks. It forces strict compliance: any plugin or build script that breaks configuration cache fails immediately rather than silently degrading. We spent two weeks fixing violations, primarily in custom tasks capturing Project instances at execution time. It was tedious, and I won’t pretend otherwise.
Result: configuration phase dropped from ~10 minutes to under 5 seconds on cache hits.
Phase 2: remote build cache with Develocity
Local build cache helps individual developers. Remote build cache helps the team. With Develocity (formerly Gradle Enterprise), CI outputs become inputs for every subsequent build.
| Metric | Before | After | Improvement |
|---|---|---|---|
| Avg CI build time | 45 min | 18 min | 60% |
| Cache hit rate | 12% | 78% | 6.5x |
| Configuration phase | 10 min | < 5 sec | ~99% |
| Daily CI compute hours | 90 hrs | 36 hrs | 60% |
The thing that actually moved the needle was cache key optimization. KMP projects generate platform-specific tasks (compileKotlinJvm, compileKotlinIosArm64, etc.), and we discovered that absolute paths in compiler arguments were invalidating caches across machines. The fix:
// build-logic/src/main/kotlin/kmp-library.gradle.kts
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
// Ensure reproducible output for cache hits
freeCompilerArgs.addAll(
"-Xno-call-assertions",
"-Xno-param-assertions"
)
}
}
// settings.gradle.kts
buildCache {
local { isEnabled = true }
remote<HttpBuildCache> {
url = uri(providers.gradleProperty("cacheUrl").get())
isPush = System.getenv("CI") != null
credentials {
username = providers.gradleProperty("cacheUser").get()
password = providers.gradleProperty("cachePass").get()
}
}
}
We also normalized $rootDir references in all custom task inputs using projectDir.asFile.relativeTo(rootDir) patterns, which alone pushed cache hit rates from 52% to 78%.
Phase 3: composite builds for convention plugins
This one took the most persistence. Most teams stuff convention plugins into buildSrc, which means any change to shared build logic invalidates the entire project. Every module recompiles from scratch.
We migrated to a composite build-logic include:
// settings.gradle.kts
pluginManagement {
includeBuild("build-logic")
}
// build-logic/settings.gradle.kts
dependencyResolutionManagement {
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
This isolates convention plugin compilation from the main build graph. Changes to build-logic only recompile the plugins themselves, not all 58 modules. Combined with configuration cache, this eliminated the single largest source of redundant dependency resolution.
Final numbers after all three phases:
| Metric | Baseline | Final | Reduction |
|---|---|---|---|
| Avg CI build time | 45 min | 12 min | 73% |
| Cache hit rate | 12% | 82% | 6.8x |
| Monthly CI cost | ~$4,000 | ~$1,150 | 71% |
| Annual CI cost | ~$48,000 | ~$13,800 | 71% |
| Dev wait time (P95) | 38 min | 9 min | 76% |
Why these three work together
These optimizations are multiplicative, not additive. Configuration cache eliminates fixed overhead, so Gradle reaches the task graph faster. That means it discovers remote cache hits sooner. And composite build isolation preserves more of those hits because plugin changes don’t blow away the whole graph.
It’s a feedback loop. Each fix makes the others more effective.
Over six months, our team of 14 engineers recovered an estimated 2,100 developer-hours. That’s roughly one full-time engineer’s entire year. I was honestly surprised by that number when we calculated it, but the math checks out.
What to do Monday morning
-
Enable configuration cache with
max-problems=0. Fix violations immediately rather than tolerating warnings. The upfront cost is days; the payoff is permanent. -
Audit your cache key inputs with
--scan. Absolute paths, timestamps, and non-deterministic task inputs are silently destroying your hit rates. Develocity’s build scan comparison tool identifies these in minutes. -
Migrate
buildSrcto compositebuild-logic. If you do one thing from this post, make it this. It decouples plugin development from application compilation and preserves cache validity across the dependency graph.