Engineering

One Gradle build, five outputs, and the cache that only helps three

Android and JVM targets cache well. Native link steps mostly do not, and that asymmetry is where your CI time goes.

MobileByteSensei 9 min read
kmpgradlebuild

TL;DR

Turning on Gradle's build cache makes a multiplatform build dramatically faster, and then stops helping. The reason is that the targets are not alike: JVM and Android compilation is highly cacheable, while Kotlin/Native compile-and-link steps are large, machine-specific, and frequently not cached at all.

So a 22-minute CI run becomes 14 minutes and refuses to go lower, because 11 of those minutes are native link steps the cache never serves. Adding more cache does nothing; the lever is which targets run, when.

Measure per target before optimising. The number that matters is not total build time, it is cache hit rate by task type.

The case this post is about

A shared module with five outputs: androidTarget(), jvm(), iosArm64(), iosSimulatorArm64(), wasmJs(). CI runs ./gradlew build on every pull request.

// build.gradle.kts
kotlin {
    androidTarget()
    jvm()
    iosArm64()
    iosSimulatorArm64()
    wasmJs { browser() }
}
# gradle.properties
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.configuration-cache=true

Everything the documentation recommends, enabled. And it did help — the first time.

The symptom

A CI timeline that stops improving:

no cache            22m 10s
+ build cache       14m 02s   ← the win everyone gets
+ more cache nodes  13m 51s
+ bigger runner     13m 20s   ← 2x cores, 4% faster

The last two rows are the symptom. More cache and more CPU barely move a build that is nominally parallel and nominally cached, which is the signature of a bottleneck that is neither.

The per-task breakdown explains it, and it took a build scan to see:

task                                        duration   cache
:shared:compileKotlinJvm                       0.4s    FROM-CACHE
:shared:compileDebugKotlinAndroid              0.6s    FROM-CACHE
:shared:compileKotlinWasmJs                    1.1s    FROM-CACHE
:shared:compileKotlinIosArm64                 86.2s    (none)
:shared:compileKotlinIosSimulatorArm64        79.4s    (none)
:shared:linkDebugFrameworkIosArm64           214.0s    (none)
:shared:linkDebugFrameworkIosSimulatorArm64  198.1s    (none)

Three targets served from cache in two seconds. Two targets spending nearly ten minutes with no cache involvement at all.

Why the obvious fix failed

"The cache must be misconfigured." It was not — the JVM, Android and wasm tasks were being served, which proves the cache works. A cache that serves some tasks and not others is usually telling you something about the tasks.

"Mark the native tasks cacheable." You cannot usefully force this from outside, and the reasons the Kotlin/Native tasks are not cached by default are real: the outputs are large, the toolchain and Xcode version participate in correctness, and a wrong hit produces a broken binary rather than a slow build. This is a case where the default is protecting you.

"Use a remote cache with more storage." We did, and the native tasks still were not stored. A remote cache changes where entries live, not which tasks produce them.

"Parallelise harder." org.gradle.parallel=true parallelises across projects, and this is one project with several targets. The native compile and link for a single target are also internally sequential. There was less parallelism available than the flag suggested.

"Run only the changed target." The right idea, and my first version of it was wrong in a way worth admitting: I made CI build only Android on pull requests. Two weeks later a Kotlin/Native compile error reached the main branch, because nothing had compiled the iOS target since the change that broke it. Skipping a target does not make it work; it makes you find out later.

The actual mechanism

Three separate facts, which together produce the plateau.

JVM-family compilation is cheap to cache. The inputs are source files and a classpath; the output is class files. Deterministic, modest in size, and unaffected by the machine — exactly the shape a task cache is built for.

Kotlin/Native compilation and linking are not. Each target compiles to a .klib and then links a real framework binary, pulling in the Kotlin/Native runtime. The outputs are tens to hundreds of megabytes, and correctness depends on the toolchain, the Xcode version, and the platform SDK. Caching that safely requires those to be part of the key — which is exactly why it is conservative by default.

The cost scales with targets, not with changed code. This is the part that surprises people. One line changed in commonMain invalidates the native compile for every native target, and each one pays full price. Two iOS targets means paying twice for one edit.

A build graph splitting into five outputs: the JVM, Android and wasm branches are served from cache in about two seconds total, while the two Kotlin/Native branches each pay a full compile and a full framework link with no cache participation — about ten of the fourteen minutes, scaling with the number of native targets rather than with the amount of changed code

So:

A build cache rewards small deterministic outputs. Native link steps are large, toolchain-dependent artifacts, so they are the one part of a multiplatform build that scales with how many targets you have rather than how much you changed.

Which reframes the problem. The question is not how to cache native builds. It is how often each target genuinely needs to be built.

That question has a mechanical answer, because the source set a change lands in determines which targets it can possibly affect:

#!/usr/bin/env bash
# targets-for <base-ref> — which compilations can this diff actually affect?
set -euo pipefail

changed=$(git diff --name-only "$1"...HEAD)
native=false; jvm=false; wasm=false; everything=false

while IFS= read -r file; do
  case "$file" in
    # Anything shared, or any build input, can reach every target. Bail out early —
    # a wrong "skip" here is a broken release, which costs far more than a full build.
    *commonMain/*|*/build.gradle.kts|gradle/libs.versions.toml|*.properties)
      everything=true; break ;;
    *iosMain/*|*appleMain/*|*nativeMain/*) native=true ;;
    *androidMain/*|*jvmMain/*|*desktopMain/*) jvm=true ;;
    *wasmJsMain/*|*jsMain/*) wasm=true ;;
    *) ;;                                    # docs, CI config, assets — no compilation
  esac
done <<< "$changed"

if $everything; then echo "allTests assembleRelease"; exit 0; fi

tasks=""
$jvm    && tasks="$tasks jvmTest androidUnitTest"
$native && tasks="$tasks iosSimulatorArm64Test linkDebugFrameworkIosArm64"
$wasm   && tasks="$tasks wasmJsTest"

# No compilation affected is a real answer, and must not silently mean "run nothing
# and report success" — say so, so a green tick is never ambiguous.
[ -n "$tasks" ] && echo "$tasks" || echo "NO_COMPILATION_AFFECTED"

On the named case — the week's pull requests, replayed against it:

PR #412  feature/ios/SharePanel.kt           → iosSimulatorArm64Test linkDebugFrameworkIosArm64
PR #413  commonMain/CheckoutState.kt         → allTests assembleRelease        (full 14m)
PR #414  docs/ARCHITECTURE.md                → NO_COMPILATION_AFFECTED
PR #417  androidMain/BiometricPrompt.kt      → jvmTest androidUnitTest

Three of four avoided the native path entirely. The commonMain case still pays full price and always will — that is the honest half, and it is why the ordering in the fix below starts with reducing how often commonMain changes rather than with build flags.

The fix

Separate the signal each target provides from its cost, and schedule accordingly.

Compile every target on every PR; link only when needed. This is the main lever, and it holds because the two tasks answer different questions. compileKotlinIosArm64 is what catches a Kotlin/Native compile error. linkDebugFrameworkIosArm64 produces a binary nobody downloads on a PR.

# PR: every target compiles — nothing is skipped, so nothing rots
./gradlew :shared:compileKotlinJvm \
          :shared:compileDebugKotlinAndroid \
          :shared:compileKotlinWasmJs \
          :shared:compileKotlinIosArm64 \
          :shared:compileKotlinIosSimulatorArm64 \
          :shared:allTests

# main / release: the full build, frameworks and all
./gradlew build

Drop the redundant simulator target on PRs only. iosArm64 and iosSimulatorArm64 compile the same shared source against different ABIs. For catching source-level errors, one is enough; the other runs on main. (The simulator target is the one that matters for iosTest, so if you run native tests on PRs — and you should — keep that one and defer the device target instead.)

Keep the Kotlin/Native compiler cache warm. Distinct from the Gradle build cache, and it does help:

# gradle.properties
kotlin.native.cacheKind=static

On CI, the directory it lives in needs to be preserved between runs like any other cache:

- uses: actions/cache@v4
  with:
    path: ~/.konan
    key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }}

Measure the hit rate, not the wall clock. A build scan per CI run, and one number watched over time: tasks served from cache as a fraction of cacheable tasks. Wall clock moves for a dozen reasons; hit rate moves when something is actually wrong with caching.

Result:

PR build     13m 20s → 5m 48s       (both iOS targets still compiled)
main build   13m 20s               (unchanged — full link, deliberately)

The PR number is the one developers wait on. The main number is allowed to be slow, because nobody is sitting watching it.

How we proved it

The claim has two halves and they need different evidence.

That the native steps are the cost. A build scan with the task-level timings and cache outcomes, which is the table in the symptom section. Per-task data rather than totals is the whole difference between "the build is slow" and "two tasks are 71% of the build".

That dropping the link step does not drop the signal. This is the half I had to prove to myself after getting it wrong earlier. Introduce a deliberate Kotlin/Native-only compile error — an unresolved NSString reference in iosMain — and check which configuration catches it:

iOS compile only (new PR config)     FAILED  compileKotlinIosArm64   ✓ caught
full build (main config)             FAILED  compileKotlinIosArm64   ✓ caught
Android-only (my earlier mistake)    SUCCESS                         ✗ missed

That third row is the regression I shipped and then reverted. It is the reason the fix compiles every target rather than skipping one.

Then the inverse check, to make sure the saving comes from where I think: an edit in commonMain, times for the two configurations, run three times each on the same CI machine class.

                     run 1    run 2    run 3
PR config            5m51s    5m44s    5m49s
full build          13m18s   13m25s   13m20s

Two limits worth stating. These numbers are from one project on macos-14 runners; the ratio depends on how much native code you have and how fast your runners are. And the compile/link split holds only while nothing on a PR needs the framework binary — add a CI job that runs an iOS UI test and the link cost returns, legitimately.

What this generalizes to

An aggregate number hides asymmetry. "The build takes 14 minutes" is not actionable; "two tasks take ten of them and are never cached" is. Before optimising anything with a mixed workload, get the per-unit breakdown — the fix is almost always specific to one unit.

A cache rewards small deterministic outputs. Where a step produces a large, environment-dependent artifact, caching is either unsafe or not worth it. That is a property of the step, not a configuration you forgot, and the lever moves to how often the step runs.

Separate "does it compile" from "does it produce an artifact". They have very different costs and very different value on a pull request. Running the cheap one always and the expensive one on merge is a general pattern, and it beats skipping the target entirely.

Skipping a check does not remove the work, it defers it to a worse moment. My Android-only PR build was fast and let a broken iOS build reach main, where it blocked everyone. Fast feedback that does not cover the thing being changed is not feedback.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS