MVP Factory
ai startup development

Gradle build cache deep dive: how we cut 70% of redundant KMP compilation

KW
Krystian Wiewiór · · 6 min read

Meta 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, kmp, multiplatform, devops, cicd


TL;DR

Gradle’s build cache uses content-addressable hashing of task inputs to skip redundant work, but KMP modules introduce relocatability pitfalls with expect/actual declarations, annotation processors, and compiler flag drift. By deploying a self-hosted S3-compatible remote cache, normalizing non-deterministic inputs, and eliminating timestamp poisoning in BuildConfig, we reduced total CI compilation time by 70% across 48 KMP modules. This post covers how the hashing works, what breaks it, and how to fix it.


How Gradle’s build cache actually works

Every cacheable Gradle task produces a cache key, a SHA-256 hash computed from the task’s declared inputs. This is content-addressable storage: the hash is derived from what goes in, not when or where it runs.

The fingerprinting process hashes these components in order:

  1. Task implementation class: the fully qualified class name and classloader hash
  2. Task action implementations: bytecode hashes of any registered actions
  3. Input properties: each @Input, @InputFile, @InputDirectory value, normalized and hashed
  4. Classpath inputs: @Classpath annotated inputs use ABI-aware hashing (method signatures, not debug info)

For file inputs, Gradle computes hashes using the file content and the path sensitivity setting. This is where most teams start leaking cache misses without realizing it.

Path sensitivity modes

ModeWhat gets hashedUse case
ABSOLUTEFull absolute path + contentAlmost never correct for caching
RELATIVEPath relative to root + contentDefault for most inputs
NAME_ONLYFilename + contentResources, assets
NONEContent onlyOrder-independent file collections

What most teams get wrong: the default RELATIVE sensitivity means that if your project lives at /Users/alice/dev/app locally but /home/runner/work/app on CI, the cache keys will differ for any task that hasn’t explicitly declared relocatability. You get zero cache sharing between local and CI despite identical source code.

KMP-specific relocatability pitfalls

Kotlin Multiplatform complicates caching because expect/actual declarations create cross-module input dependencies that Gradle must track. The KotlinCompile task for a shared module depends on the compiled output of platform-specific actual implementations, and those outputs carry path information.

In my experience building production KMP systems, three issues account for most cache misses:

1. Kotlin compiler flag drift between IDE and CLI

IntelliJ injects compiler arguments that differ from your build.gradle.kts configuration. The most common offender:

// IDE silently adds this; CLI does not
-Xuse-k2=true
-Xjvm-default=all

Since compiler arguments are task inputs, this silently produces different cache keys. The fix: explicitly lock all compiler flags in your build script.

tasks.withType<KotlinCompile>().configureEach {
    compilerOptions {
        jvmTarget.set(JvmTarget.JVM_17)
        freeCompilerArgs.addAll("-Xjvm-default=all")
    }
}

2. BuildConfig timestamp poisoning

If your BuildConfig includes a build timestamp, every single build produces a unique artifact. Anything downstream of that module recompiles.

// 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.

3. Annotation processor non-determinism

Certain annotation processors (Dagger/Hilt, Room) generate code with non-deterministic ordering. HashMap iteration order leaks into generated source files. The content changes between runs, invalidating the cache.

Before we addressed these three issues, our cache hit rate on CI sat at 23%. After: 87%.

Remote cache setup: S3-compatible object storage

You do not need Gradle Enterprise for a remote cache. Any S3-compatible backend works. We use MinIO on a single VM, total cost under $20/month.

// settings.gradle.kts
buildCache {
    local { isEnabled = true }
    remote<HttpBuildCache> {
        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 should push to the remote cache. Local machines pull only. If you let developer machines push, you’ll poison the cache with environment-specific artifacts and spend a week figuring out why hit rates tanked.

Before/after build scan data

Across our 48 KMP modules (shared, Android, iOS, desktop targets), here are the results after deploying remote caching with input normalization:

MetricBeforeAfterChange
Full CI build (clean)14m 32s4m 18s-70.4%
Incremental CI build8m 45s2m 12s-74.8%
Cache hit rate (CI)23%87%+64pp
Cache hit rate (local)0%72%+72pp
Cache storage (30-day)N/A4.2 GB

The local hit rate going from zero to 72% was honestly the biggest win. Engineers pulling main and building immediately get most artifacts from the remote cache instead of recompiling everything. The CI time savings are nice, but that feeling of git pull && ./gradlew build finishing fast is what actually changed how the team worked day to day.

Debugging cache misses

When a task misses the cache unexpectedly, use the build scan comparison or run:

./gradlew :shared:compileKotlinJvm --build-cache -Dorg.gradle.caching.debug=true

This logs every input that contributes to the cache key. Diff two runs to find the divergent input. Nine times out of ten, it’s an absolute path, a timestamp, or a compiler flag you didn’t know was being injected.

What to do with all this

Audit your task inputs with caching.debug=true. Absolute paths, timestamps, and non-deterministic annotation processors are the top three cache killers in KMP projects. Fix them before deploying a remote cache or you’re just caching misses faster.

Deploy a self-hosted S3-compatible remote cache with CI-only push. MinIO or any compatible backend eliminates the need for Gradle Enterprise while giving you full cache sharing between CI runs and local builds.

Lock every Kotlin compiler flag explicitly in your build script. IDE-injected arguments are invisible cache key mutations. If it’s not declared in build.gradle.kts, it will eventually diverge and silently wreck your hit rate.


Share: Twitter LinkedIn