An Android app is already written in Kotlin. Kotlin Multiplatform (KMP) asks a simple question: how much of that Kotlin could also run on iOS, on the desktop and in the browser, unchanged? The marketing answer is “most of it”; this post measures it. It builds a small app that runs on all four, compiles it for each target, and counts, line by line, what a real multiplatform app shares and what stays platform-specific.
This is the first post of a chapter on Android and Kotlin Multiplatform. The same small app, a reading list, grows through the chapter: platform code with expect and actual, a shared data layer, shared state and navigation, Compose Multiplatform UI, the iOS boundary, the build, and offline sync. The app below is not a picture of it: it is the app, compiled from the chapter’s shared Kotlin code to WebAssembly and running in this page.
1. One Language, Several Compilers
Kotlin started on the JVM, which is how it runs on Android: Kotlin compiles to JVM bytecode, which the Android build converts to DEX. The JVM is not available on iOS, and the browser does not run JVM bytecode either, so Kotlin has more than one backend:
| Backend | Produces | Runs on |
|---|---|---|
| JVM | bytecode | Android (after D8/R8 convert it to DEX), desktop and server JVMs |
| Native | machine code through LLVM | iOS (as a framework that Swift or Objective-C imports), macOS, Linux, Windows |
| Wasm | WebAssembly, using the WebAssembly garbage-collection proposal | browsers |
| JS | JavaScript | browsers and Node.js |
All of them share the same frontend, the K2 compiler, which parses and type-checks the code once and hands an intermediate representation to each backend. The result on every platform is compiled code. There is no interpreter and no bridge at run time: on iOS, a Kotlin function is a native function in a native framework, which is the main technical difference from frameworks that run a separate runtime next to the platform’s own.
2. Source Sets: Where Each Line Goes
A KMP module does not have one src/main. It has source sets, and the build decides which ones each target compiles:
commonMainis compiled for every target. Code there may only use what exists everywhere: the Kotlin standard library and multiplatform libraries.android.content.Context,java.io.FileorUIKitare not visible.- Platform source sets (
androidMain,iosMain,jvmMain,wasmJsMain, …) are compiled only for their target and see everything that platform offers. - Intermediate source sets sit in between and are shared by a group of targets. Kotlin’s default hierarchy creates the common groups automatically (for example
appleMainfor all Apple targets andiosMainfor all iOS ones); a project can add its own, such as thenonAndroidMainused below.
When shared code needs something only a platform can provide, it declares it with expect in a common source set and each platform supplies an actual. That mechanism is the subject of the next post; here it is enough to count how often it is needed.
3. KMP and Compose Multiplatform Are Not the Same Thing
Two names are often used interchangeably:
| Kotlin Multiplatform | Compose Multiplatform | |
|---|---|---|
| Shares | Kotlin code: models, logic, networking, storage, state | the UI, written with Compose |
| Made by | JetBrains (the Kotlin compiler and Gradle plugin); Google supports it officially for Android | JetBrains, built on Google’s Jetpack Compose |
| UI on each platform | your choice: Jetpack Compose on Android, SwiftUI or UIKit on iOS, … | Compose everywhere; on Android it is Jetpack Compose, elsewhere Compose draws with Skia |
An app can share only its logic (KMP with native UIs), or its logic and its UI (KMP plus Compose Multiplatform). The reading list does the second, which is why the same screen appears on every target.
The official stability levels, as listed by JetBrains (last updated 10 September 2025): KMP is Stable for Android, iOS, desktop and server JVMs and for web via Kotlin/JS, and Beta for web via Kotlin/Wasm; Compose Multiplatform is Stable on Android, iOS and desktop and Beta on the web. KMP itself was declared stable in November 2023, Google announced official support for sharing business logic with it in May 2024, and Compose Multiplatform for iOS became stable with version 1.8.0 in May 2025.
4. The Reading List
The chapter’s app is deliberately small: a list of books, a form to add one, a checkbox to mark it read. It uses Kotlin 2.4.21, Compose Multiplatform 1.12.2, the Android Gradle plugin 9.4.1 and Gradle 9.8.1, and follows the module layout that AGP 9 requires: a shared KMP module and a separate androidApp module, because with AGP 9 the Android application plugin can no longer be combined with the KMP plugin in one module.
reading-list/
├── shared/ # the KMP module
│ └── src/
│ ├── commonMain/ # state, rules and UI: ReadingList.kt, App.kt, Platform.kt
│ ├── androidMain/ # actual fun platformName()
│ ├── jvmMain/ # actual + desktop main()
│ ├── wasmJsMain/ # actual + browser main(), index.html
│ └── iosMain/ # actual + MainViewController() (built when Xcode is present)
└── androidApp/ # MainActivity: setContent { App() }The state and rules are plain Kotlin in commonMain:
class ReadingList(initial: List<Book> = SAMPLE) {
val books = mutableStateListOf<Book>().apply { addAll(initial) }
private var nextId = (initial.maxOfOrNull { it.id } ?: 0) + 1
val readCount: Int get() = books.count { it.read }
fun add(title: String, author: String): Boolean {
val t = title.trim()
if (t.isEmpty() || books.any { it.title.equals(t, ignoreCase = true) }) return false
books += Book(nextId++, t, author.trim().ifEmpty { "Unknown" })
return true
}
fun toggle(id: Int) { /* … */ }
fun remove(id: Int) { books.removeAll { it.id == id } }
}and the screen, App(), is Compose in commonMain too. The only thing the shared code asks each platform for is its name, through one expect declaration:
// commonMain
expect fun platformName(): String
// androidMain
actual fun platformName(): String = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})"
// jvmMain
actual fun platformName(): String = "desktop JVM ${System.getProperty("java.version")} on ${System.getProperty("os.name")}"
// wasmJsMain
actual fun platformName(): String = "the browser (Kotlin/Wasm)"
// iosMain
actual fun platformName(): String = "${UIDevice.currentDevice.systemName()} ${UIDevice.currentDevice.systemVersion}"Each platform then has an entry point of a few lines that hosts App(): an Activity with setContent { App() } on Android, application { Window { App() } } on the desktop, and ComposeViewport(document.body!!) { App() } in the browser. This is the browser build, running here:
(It is about 4.5 MB to download, so it may take a moment; the line under the title says where it runs. Add a book, tick one off: all of it is shared commonMain code. The page embeds the chapter’s latest version of the app, which since the next post also saves the list and can share a book; the code and sizes in this post are those of version 1, in the download below.)
To build it yourself, download the project (reading-list.zip, 54 KB), unzip it, and with JDK 21 and the Android SDK installed (ANDROID_HOME set, or a local.properties with sdk.dir):
cd reading-list
./gradlew :androidApp:assembleRelease # Android APK
./gradlew :shared:run # desktop window
./gradlew :shared:hotRunJvm # the same, with Compose Hot Reload (needs JetBrains Runtime 25)
./gradlew :shared:wasmJsBrowserDistribution # the browser build in shared/build/dist/wasmJs
./gradlew -Pkmp.ios=true :shared:linkDebugFrameworkIosSimulatorArm64 # iOS framework (needs Xcode)Of the app’s 80 lines of code, 61 (76%) are in commonMain. The rest is the four one-line actuals and the entry points, which a project of this size cannot avoid; a real app shows the proportions better.
5. How Much Does a Real App Share?
Now in Android is Google’s sample app, used to show modern Android practice: a modular codebase with Compose, Hilt, Room, DataStore and WorkManager. Now in Android KMP is a public, open-source port of it to Kotlin Multiplatform that runs on Android, iOS, desktop and the web. Counting its Kotlin code (excluding blank lines, comments, package and import lines, and tests; commit 402e84e, September 2026):
| Source set | Lines of code | Share |
|---|---|---|
commonMain | 7,376 | 89.5% |
androidMain + the androidApp module | 371 + 46 | 5.1% |
iosMain | 145 | 1.8% |
desktopMain | 121 | 1.5% |
wasmJsMain | 121 | 1.5% |
nonAndroidMain (shared by iOS, desktop, web) | 63 | 0.8% |
| Total | 8,243 in 228 files |
What matters for each platform is what it compiles: commonMain plus the source sets on its path.
| Target | Lines it compiles | Of which written once in commonMain |
|---|---|---|
| Android | 7,793 | 94.6% |
| iOS | 7,584 | 97.3% |
| desktop | 7,560 | 97.6% |
| web | 7,560 | 97.6% |
The tests are shared the same way: 1,800 lines in commonTest run on every target, with 318 more in nonAndroidTest and 369 desktop-only.
The whole platform surface: 14 declarations
The clearest measure of how much is platform-specific is the list of expect declarations, the places where shared code says “a platform must fill this in”. The entire port has 14:
| Kind | Declarations | Where the actuals live |
|---|---|---|
| Platform services (5): different on every target | NiaLogger, ioDispatcher, httpClientEngineFactory (the Ktor engine), rememberUrlLauncher, imageHttpClient | one actual per target (Android, iOS, desktop, web) |
| Android-only features (8): real on Android, a fallback elsewhere | dynamic colour (supportsDynamicTheming, dynamicNiaColorScheme), NotificationPermissionEffect, ReportFullyDrawnWhen, JankStats (rememberMetricsStateHolder), drag and drop, test tags as resource ids, the image loader | one in androidMain, one shared in nonAndroidMain |
| Generated (1) | NiaDatabaseConstructor | written by Room’s KSP processor at build time |
Everything else, the screens, view models, repositories, the network models, the database schema and its queries, the design system, is the same code on all four platforms.
Compared with the original
The original Android-only app has 9,416 lines of production code (and 5,710 of tests). The port is smaller, but not because KMP code is shorter: several parts of the original were not ported, a custom lint module (386 lines), macrobenchmarks (328), a design-system catalog app (323), background sync with WorkManager (229) and screenshot testing (181), and the app module’s 1,238 lines became a 601-line app/shared plus three small platform app modules. The design system went from 2,778 to 2,135 lines. A fair reading is that the features that were ported needed few platform-specific lines; what was dropped was largely Android tooling (lint, benchmarks, WorkManager) that has no direct counterpart on the other targets.
6. What Each Target Ships
Sharing code does not mean sharing the cost of running it. The same reading list, built for each target:
| Build of the same app | Size | What is in it |
|---|---|---|
| Android release APK, minified by R8 | 1.21 MB | the app and the parts of Jetpack Compose it uses; Android itself already contains the renderer |
| Web (Kotlin/Wasm) | 12.5 MB, 4.45 MB compressed | 8.65 MB is Skia compiled to Wasm (69%), 3.35 MB the app with Compose, 0.53 MB a JavaScript loader |
| Desktop “uber JAR” | 45.2 MB | Compose for desktop and Skia’s native libraries; it still needs a JVM, which an installer would add |
The web number is the one to keep in mind. Compose Multiplatform does not translate the UI into HTML elements: it ships its own renderer, Skia, and draws every pixel onto a canvas, exactly as it does on iOS and the desktop. That gives identical rendering everywhere, and it costs a download of several megabytes before the first frame. It also means the page’s content is not ordinary HTML, which matters for search engines and for some browser features (Compose provides its own accessibility tree). For an app inside a web page that is acceptable; for a content website it usually is not, which is why JetBrains is exploring Compose HTML for server-side rendering separately.
7. Common Claims, Checked
| Claim | Verdict |
|---|---|
| ”Kotlin Multiplatform shares the UI.” | KMP shares Kotlin code; sharing the UI is what Compose Multiplatform adds. Many apps share only logic and keep SwiftUI on iOS. |
| ”Write once, run everywhere.” | Close, measured: Now in Android KMP writes 94.6% (Android) to 97.6% (desktop, web) of what each target compiles once, but needs 14 platform declarations and five small platform modules. |
| ”On iOS, Kotlin runs in a JVM or an interpreter.” | Kotlin/Native compiles to machine code through LLVM and ships as a native framework; there is no JVM and no bridge at run time. |
”commonMain can call Android APIs.” | Only APIs available on every target of the source set. Android-only calls go behind expect/actual, an interface, or an Android source set. |
| ”The Android app has to be rewritten to go multiplatform.” | Most of Now in Android’s app code became commonMain; Jetpack libraries such as ViewModel, Lifecycle, Room, DataStore, Paging and Navigation 3 now publish multiplatform artifacts. What did not move was Android tooling (lint, benchmarks, WorkManager sync). |
| ”KMP is experimental.” | KMP has been stable since November 2023, with official Google support for Android since May 2024. Web via Kotlin/Wasm, and Compose Multiplatform on the web, are Beta. |
| ”A Compose Multiplatform web app is as light as a web page.” | The reading list downloads 4.45 MB compressed, 69% of it Skia; the Android release APK is 1.21 MB. |
| ”The original app is bigger, so KMP code is more compact.” | The port dropped lint, benchmarks, a catalog app, WorkManager sync and screenshot tests; the comparison mostly reflects what was ported. |
Summary
| Idea | In one line |
|---|---|
| Backends | One frontend (K2), several backends: JVM (Android, desktop), Native (iOS), Wasm and JS (web) |
| Source sets | commonMain everywhere, platform and intermediate source sets on each target’s path |
| KMP vs. CMP | KMP shares Kotlin code; Compose Multiplatform also shares the UI |
| Measured sharing | Now in Android KMP: 89.5% of its code in commonMain; 94.6–97.6% of what each target compiles |
| Platform surface | 14 expect declarations: platform services, Android-only features, one generated |
| Cost | Same code, different payloads: 1.21 MB APK, 4.45 MB compressed web build, 45 MB desktop JAR |
Next: expect and actual, Done Right: Platform Code That Cannot Crash: what expect and actual guarantee and what they do not, the Context problem, features that only some platforms have, and thirteen mistakes compiled for every target.
The app: reading-list (zip) (Kotlin 2.4.21, Compose Multiplatform 1.12.2, AGP 9.4.1, Gradle 9.8.1). The measurements: measure_share.py, run on Now in Android KMP at commit 402e84e and Now in Android at commit a49ed25; the sizes are from this machine’s builds (macOS, Apple silicon). The iOS target is in the code but was not built for this post, which needs Xcode.
References
Kotlin Multiplatform
- JetBrains. Kotlin Multiplatform documentation: source sets and the hierarchical project structure.
- JetBrains. Stability of supported platforms (last updated 10 September 2025).
- JetBrains (2023). Kotlin Multiplatform is stable and production-ready. November 2023.
- Android Developers (2024). Android support for Kotlin Multiplatform to share business logic across mobile, web, server, and desktop. May 2024.
- Android Developers. Kotlin Multiplatform and Set up the Android Gradle library plugin for KMP.
Compose Multiplatform
- JetBrains. Compose Multiplatform and its documentation.
- JetBrains (2025). Compose Multiplatform 1.8.0 released: Compose Multiplatform for iOS is stable and production-ready. May 2025.
- JetBrains. Skiko: Kotlin bindings for Skia, the renderer used on iOS, desktop and the web.
The apps measured
- Google. Now in Android (Apache 2.0).
- Jaewoong Eum (skydoves). Now in Android KMP (Apache 2.0).