Five posts in, the reading list builds for four targets, runs Room’s code generator for each, and has shared tests on three runtimes. Every post so far has said “build every target”, because most mistakes break only one. This post asks what that costs. It measures each target’s build from clean and after one-line edits, what Gradle’s configuration cache and build cache save, and how long each target’s tests take. It then writes the continuous-integration pipeline that does the checking automatically, including the one thing this Mac cannot do: link and test iOS.

Every number below is the median of three runs on a warm Gradle daemon, on an Apple M1 Pro with 8 cores. One measurement run had to be thrown away, and the reason is in Section 5.

1. What Each Target Costs from Clean

“From clean” here means: the project’s build directories deleted, the Gradle daemon warm, dependencies already downloaded.

TargetFrom cleanTasks run
desktop: a JAR5.6 s13
iOS: compile only3.9 s10
Android: release APK43.1 s78
web: production bundle68.3 s31
all four together107.9 s117
Androidrelease APKKSP (Room)Kotlin → JVM bytecodeD8 → DEXR8: shrink, optimisepackage, sign43.1 s from cleandesktopJARKSP (Room)Kotlin → JVM bytecodeJAR5.6 s from cleanwebproduction bundleKSP (Room)Kotlin → Wasmwasm-opt (Binaryen)npm installwebpack68.3 s from cleaniOScompile onlyKSP (Room)Kotlin → kliblink framework: needs Xcode3.9 s from cleanThe Kotlin compiler (highlighted) is the cheap part: a few seconds per target. Android's release and the web bundle spend most of their time after it.All four together: 108 s from clean. Only compiling every target, to check it: 7.2 s.

The Kotlin compiler is not where the time goes. The desktop build, which is little more than compiling and packing, takes 5.6 seconds; the iOS code compiles in 3.9. The two expensive targets spend most of their time after the compiler: Android’s release build runs R8, which shrinks and optimises the whole app with its libraries, and the web’s production build runs Binaryen’s wasm-opt over the WebAssembly and then webpack. Together the four take 107.9 seconds, less than their sum (120.9 s): the shared work (Room’s code generation for the common code, the metadata) runs once, and several plugins run their work in parallel worker threads.

The cheapest check of every target

Post 2 showed that most expect/actual and source-set mistakes break exactly one target. Catching them does not need an APK or a web bundle, only every target’s Kotlin compiled:

./gradlew -Pkmp.ios=true :shared:compileCommonMainKotlinMetadata :shared:compileNonAndroidMainKotlinMetadata \
  :shared:compileAndroidMain :shared:compileKotlinJvm :shared:compileKotlinWasmJs :shared:compileKotlinIosSimulatorArm64

From clean, this takes 7.2 seconds, 7% of building everything, and covers all six compilations from post 2’s experiment. It is the first thing the CI pipeline in Section 4 runs.


2. What a One-Line Change Rebuilds

After a full build, three edits were made one at a time, and all four targets were built again:

EditRebuild, all four targetsCompile and packaging tasks that ran
a function body in commonMain (a string)86.1 sKotlin and Room’s KSP for every target, Android’s R8, the web’s wasm-opt and webpack (19 tasks)
a new public function in commonMain46.5 sthe same Kotlin, KSP and R8, plus androidApp’s own Kotlin; not wasm-opt or webpack (17 tasks)
a function body in androidMain27.4 sAndroid only: KSP, Kotlin and R8 (8 tasks)

Three things stand out.

  • A change in commonMain reaches every target, and the expensive steps after them. Changing one string inside a function took 86.1 seconds, 80% of a clean build: Room’s code generator ran again for each of the four targets, and R8 and the web optimiser redid all of their work, because their input had changed.
  • The bigger-looking change was the cheaper one. Adding a public function changed the shared module’s public API, so androidApp, which depends on it, recompiled too (compileReleaseKotlin), which a body change does not cause. But the new function is never called, the web compiler’s dead-code elimination removed it, the production web binary came out byte-for-byte the same, and wasm-opt and webpack were skipped: 46.5 seconds instead of 86.1.
  • A change in androidMain stays on Android. 27.4 seconds, almost all of it R8; no other target’s task ran. The first version of this measurement appended + "" to a string, which the compiler folds away: the bytecode did not change, nothing downstream ran, and the edit measured nothing. The real edit changes an argument.

During development nobody builds release variants on every change; these numbers are what CI pays, and what a developer pays who runs “build everything” locally. The cheap check above is what catches mistakes.


3. Two Caches

Gradle has two caches that are easy to confuse, and both were measured.

The configuration cache stores the result of Gradle’s configuration phase, the work of reading every build script and deciding which tasks to run. With nothing changed, building all four targets took 1.2 s with it and 2.1 s without. That second saved per build is small for a project of two modules, and it grows with the build.

Turning it on took one fix, and the fault was this chapter’s own. Post 3’s workaround for the stale web worker deleted two files from a doFirst block using the build script’s delete() function, which keeps a reference to the build script, and the configuration cache refused to store the build:

2 problems were found storing the configuration cache.
- Task `:kotlinWasmNpmInstall`: cannot serialize Gradle script object references …

The fix resolves the files while configuring and deletes plain Files when the task runs:

val workerCopy = layout.buildDirectory.dir("wasm/node_modules/reading-list-sqlite-worker").get().asFile
val yarnIntegrity = layout.buildDirectory.file("wasm/node_modules/.yarn-integrity").get().asFile
tasks.matching { it.name == "kotlinWasmNpmInstall" }.configureEach {
    inputs.dir("shared/sqlite-worker").withPropertyName("sqliteWorker")
    val copy = workerCopy        // locals: the action must not reach back into the script
    val integrity = yarnIntegrity
    doFirst {
        copy.deleteRecursively()
        integrity.delete()
    }
}

Even the first attempt at a fix failed. Top-level vals in a build script are properties of the script object, so the action still captured the script, and only copying them into locals made the build cacheable. The Kotlin Multiplatform, Compose, Room, KSP and Android plugins all worked with the configuration cache without a change.

The build cache stores task outputs by their inputs, so a task whose inputs were seen before is restored instead of run. From clean, with a cache filled by an earlier build of the same sources, all four targets took 47.7 s instead of 107.9: 40 tasks came from the cache and 77 still ran. Gradle does not cache every task (copying, syncing and checks, among others, are cheaper to redo than to store), so a cached build is far from instant, but it skips the compilers and, where their inputs match, R8 and the web optimiser. In CI, where every run starts from a clean checkout, this is the cache that matters, and gradle/actions/setup-gradle keeps it between runs.


4. A Pipeline That Checks Everything

Each target’s tests, run again with everything already built:

TestsTime
desktop JVM7.4 s
web (Chrome)7.3 s
Android emulator7.3 s

Starting the emulator took a further 23 seconds (a cold boot, no snapshot), which CI pays on every run.

pushor pull requestcheck (Linux)compile every target but iOS,tests on the JVM and in Chrome, buildandroid (Linux + emulator)the shared tests on Android,Android's own SQLiteios (macOS, with Xcode)compile, link the framework,the shared tests on the iOS simulatorThe cheap job runs first and catches most mistakes (post 2: most break one target only). The expensive ones run only if it passes,and do what this Mac cannot: an emulator on every push, and an iOS link with Xcode.

The pipeline follows from the measurements: the cheap work first, on Linux, where a CI minute costs least; the expensive work only if it passes.

jobs:
  check:                      # Linux, a few minutes: catches most mistakes
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v6
        with: { distribution: temurin, java-version: 21 }
      - uses: gradle/actions/setup-gradle@v6        # dependencies and the build cache, kept between runs
      - run: ./gradlew :shared:compileCommonMainKotlinMetadata :shared:compileNonAndroidMainKotlinMetadata
               :shared:compileAndroidMain :shared:compileKotlinJvm :shared:compileKotlinWasmJs
      - run: ./gradlew :shared:jvmTest :shared:wasmJsBrowserTest
      - run: ./gradlew :androidApp:assembleRelease :shared:wasmJsBrowserDistribution
 
  android:                    # the shared tests on Android, in an emulator
    needs: check
    runs-on: ubuntu-latest
    steps:
      - …                                           # checkout, Java, Gradle, KVM for the emulator
      - uses: ReactiveCircus/android-emulator-runner@v2
        with: { api-level: 36, target: google_apis, arch: x86_64,
                script: ./gradlew :shared:connectedAndroidDeviceTest }
 
  ios:                        # what only a Mac with Xcode can do
    needs: check
    runs-on: macos-latest
    steps:
      - …
      - run: ./gradlew -Pkmp.ios=true :shared:linkDebugFrameworkIosSimulatorArm64 :shared:iosSimulatorArm64Test

The full file is in the download as .github/workflows/ci.yml. Three decisions in it are worth explaining.

  • iOS is not compiled on Linux. Kotlin/Native can produce Apple libraries on any host, but not when the project or a dependency uses C interop, and Room’s bundled SQLite for iOS does; final binaries always need a Mac. So the check job compiles every target but iOS, and the ios job, on macOS, compiles, links and tests it.
  • The iOS job is the only place the iOS code would be linked and run. On this machine, without Xcode, the iOS code has been compiled in every post and never linked; the ios job would run commonTest on the iOS simulator, with the bundled SQLite driver, for the first time in the chapter.
  • The emulator job is separate because it is slow to start and, on GitHub’s Linux runners, needs KVM enabled first.

The workflow was written and validated as YAML, and its commands are the ones measured above, but it has not run: this blog’s repository holds the project in a subfolder, where a workflow would need a different layout, and running it would spend the repository owner’s CI minutes. Until it runs, the iOS claims in this chapter remain “compiled, not linked or run”. (Update: it has run. In The Pipeline, Run, all three jobs passed on the first push, and the shared tests ran on the iOS simulator: 19 tests, 0 failures.)


5. The Measurement That Had to Be Thrown Away

The first measurement run never finished. It was stopped after two hours, and a second attempt showed why. A production web build that took about 70 seconds from clean spent more than 15 minutes in wasm-opt, and the process had used only 43 seconds of CPU in that time. It was runnable but barely being scheduled, and the Gradle daemon beside it held 445 threads after more than 200 builds in one day, using two cores of its own. With a fresh daemon, the same builds ran at their normal speed again.

Two lessons follow, and neither is specific to Kotlin. A Gradle daemon that has lived for hours and hundreds of builds can slow everything around it, and ./gradlew --stop is a cheap first step when a build is inexplicably slow. And build timings taken on a machine that is also being used for other work, here with a background virtual machine and a browser running, are noisy; every timing in this post therefore comes with its individual runs, in the lab above.


6. The Reading List, Version 6

The app did not change in this post; the build did. Version 6 is version 5 with the configuration cache fixed, a corrected note in gradle.properties (compiling iOS works without Xcode; linking needs it), and the CI workflow. To use it, download the project (reading-list-6.zip, 88 KB), and with JDK 21 and the Android SDK:

cd reading-list-6
./gradlew --configuration-cache -Pkmp.ios=true :shared:compileCommonMainKotlinMetadata \
  :shared:compileAndroidMain :shared:compileKotlinJvm :shared:compileKotlinWasmJs :shared:compileKotlinIosSimulatorArm64
./gradlew --configuration-cache :shared:jvmTest :androidApp:assembleRelease :shared:wasmJsBrowserDistribution

7. Common Claims, Checked

ClaimVerdict
”Kotlin Multiplatform builds are slow because of the Kotlin compiler.”The desktop JAR built from clean in 5.6 s and the iOS code compiled in 3.9 s. Android’s R8 and the web’s optimiser and bundler took most of the 107.9 s.
”To check every target, build every target.”Compiling all six compilations took 7.2 s from clean, 7% of building everything.
”A change in shared code rebuilds everything.”A string changed in commonMain re-ran every target (86.1 s); one in androidMain only Android (27.4 s); and an unused new function left the web build untouched (46.5 s).
”The configuration cache does not work with KMP plugins.”Every plugin here worked; the one problem was this project’s own build script, fixed in a few lines.
”CI can build iOS on Linux.”Apple libraries can be compiled on any host, but not with C-interop dependencies such as Room’s bundled SQLite, and linking and simulator tests always need macOS with Xcode.
”Build times are a property of the project.”They depend on the machine’s state too: an hours-old daemon turned a 70-second web build into 15 minutes.

Summary

IdeaIn one line
Where the time goesNot the Kotlin compiler: R8 for Android, wasm-opt and webpack for the web
The cheap checkCompile all six compilations: 7.2 s from clean, catches what post 2’s mistakes broke
CachesConfiguration cache: 1.2 s instead of 2.1 s with nothing changed; build cache: 47.7 s instead of 107.9 s from clean
PipelineCheck on Linux first; emulator tests and an iOS link with Xcode only if it passes
HonestyThe iOS link and simulator tests are written into CI, not yet run

Next: Offline Sync Without Losing Data: Two Devices, One Server, Kotlin on Both Ends. The reading list lives on one device. The final post of the chapter adds a small server, shares the network code across all four targets with Ktor, and measures what happens when two devices change the same book while one of them is offline.

The project: reading-list-6 (zip) (Kotlin 2.4.21, Gradle 9.8.1, AGP 9.4.1, Compose Multiplatform 1.12.2). The measurements: build_measure.py, which works on its own copy of the project and its own build cache, in phases that each save their results.


References