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.
| Target | From clean | Tasks run |
|---|---|---|
| desktop: a JAR | 5.6 s | 13 |
| iOS: compile only | 3.9 s | 10 |
| Android: release APK | 43.1 s | 78 |
| web: production bundle | 68.3 s | 31 |
| all four together | 107.9 s | 117 |
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:compileKotlinIosSimulatorArm64From 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:
| Edit | Rebuild, all four targets | Compile and packaging tasks that ran |
|---|---|---|
a function body in commonMain (a string) | 86.1 s | Kotlin and Room’s KSP for every target, Android’s R8, the web’s wasm-opt and webpack (19 tasks) |
a new public function in commonMain | 46.5 s | the same Kotlin, KSP and R8, plus androidApp’s own Kotlin; not wasm-opt or webpack (17 tasks) |
a function body in androidMain | 27.4 s | Android only: KSP, Kotlin and R8 (8 tasks) |
Three things stand out.
- A change in
commonMainreaches 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, andwasm-optand webpack were skipped: 46.5 seconds instead of 86.1. - A change in
androidMainstays 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:
| Tests | Time |
|---|---|
| desktop JVM | 7.4 s |
| web (Chrome) | 7.3 s |
| Android emulator | 7.3 s |
Starting the emulator took a further 23 seconds (a cold boot, no snapshot), which CI pays on every run.
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:iosSimulatorArm64TestThe 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
checkjob compiles every target but iOS, and theiosjob, 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
iosjob would runcommonTeston 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:wasmJsBrowserDistribution7. Common Claims, Checked
| Claim | Verdict |
|---|---|
| ”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
| Idea | In one line |
|---|---|
| Where the time goes | Not the Kotlin compiler: R8 for Android, wasm-opt and webpack for the web |
| The cheap check | Compile all six compilations: 7.2 s from clean, catches what post 2’s mistakes broke |
| Caches | Configuration cache: 1.2 s instead of 2.1 s with nothing changed; build cache: 47.7 s instead of 107.9 s from clean |
| Pipeline | Check on Linux first; emulator tests and an iOS link with Xcode only if it passes |
| Honesty | The 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
- Gradle. Configuration cache and build cache.
- Gradle. The Gradle daemon.
- JetBrains. Kotlin/Native target support and compiling Apple targets on other hosts.
- Android Developers. Shrink, obfuscate, and optimize your app (R8).
- WebAssembly. Binaryen (
wasm-opt). - GitHub. setup-gradle and android-emulator-runner.