Kotlin Multiplatform
Kotlin Multiplatform shares code across platforms not through a common runtime but through a common source. commonMain is compiled separately for every declared target, and the compiler emits a per-target artifact: a JVM library for Android, a native .framework (via LLVM) for iOS, .js or .wasm for the web. There is no virtual machine, no bridge, no interpreter at runtime — and that is precisely what sets KMP apart from Flutter (which brings its own engine and draws its own pixels) and React Native (which drives native views from JavaScript over a bridge). The platform keeps its own UI and full access to its own API, and what becomes shared is the logic. The topic's central constraint follows from the same fact: since one and the same code must build for every target, commonMain sees only the common (multiplatform) standard library and cannot touch java.* or a platform SDK — and that is the commonMain boundary.
Three mechanisms let common code reach the platform, and all three work at compile time, not at runtime. The source-set hierarchy lays code out across targets: commonMain for the platform-independent part, androidMain/iosMain for the specific parts, intermediate sets (appleMain) sharing code across a subset of targets. The expect/actual seam declares an API in common code without a body, and each platform supplies an actual — and a single missing actual breaks compilation, not the runtime. The klib format is how a KMP library travels to its consumer: serialized Kotlin IR rather than JVM bytecode, which is then lowered for each target. Everything else in the topic follows from this: object freezing is gone (freeze() is still declared but does not compile), iOS interop flows through an Objective-C header and loses part of the Kotlin model, Swift export is still Alpha, and Kotlin/Wasm is Beta. The layer-by-layer breakdown is below.
Topic map
- Common and platform source sets — why
commonMainis built for every target and sees only the common stdlib, while a platform set may call the entire API of a single target. - expect/actual declarations — how common code declares an API without a body, how each platform supplies an
actual, and why the compiler checks the seam rather than the runtime. - The klib format — why a KMP dependency travels as serialized Kotlin IR rather than JVM bytecode, and how that ties it to the compiler version.
- The Kotlin/Native memory model — a tracing garbage collector instead of freezing, why
freeze()no longer compiles, and where a data race on iOS comes from. - iOS interop — the export through an Objective-C header, what is lost at the boundary (default arguments,
sealed, generics), and why Swift export is still Alpha. - Kotlin/Wasm and the web — compiling to WebAssembly on top of WasmGC, the Beta status, and its place next to Compose Multiplatform in the browser versus the old Kotlin/JS.
- The KMP library ecosystem —
Ktor,kotlinx.serialization,SQLDelightandKoinas the "common API plus platform engine" shape, and why a pure Java library cannot be called fromcommonMain. - A KMP adoption strategy — share the logic, keep the UI native; a gradual module-by-module migration with no delivery freeze, and honest trade-offs.
Common Mistakes and Traps
| Mistake | Consequence |
|---|---|
Expecting a JVM API such as java.io.File to be visible from commonMain | Common code is built for every target and sees only the common stdlib — the call will not compile for iOS |
| Treating source sets as folders Gradle merges into a single compilation | These are separate compilations, one per target; commonMain must build for every declared one |
Thinking a missing actual is a runtime crash | It is a compile error: a target without its own actual simply will not build |
Assuming an expect needs a default body in commonMain | An expect has no body at all; the body lives in each actual |
Treating the expect/actual seam as free and dragging every platform API into common code | Permissions, background work and UI stay native; you introduce the seam only when common code truly has to call them |
| Treating JVM bytecode as a portable format every Kotlin target consumes | Kotlin/Native and Kotlin/JS consume no bytecode — common code travels as a klib (serialized IR) |
Thinking a klib is a bag of ready-made binaries, one per target | Inside are serialized Kotlin declarations plus IR; the compiler lowers the binary for the target itself |
Saying freeze() was removed | It is still declared, but deprecated at the error level — the call does not compile; it did not "disappear" |
| Believing Kotlin/Native still requires freezing an object shared between threads | The old memory manager has been gone since 1.9.20; a tracing GC shares objects across threads, just like the JVM |
Reaching for freeze() as a synchronization primitive against a data race | freeze() never was one; unprotected state in commonMain races on Android too — use a Mutex |
| Expecting Kotlin default arguments to survive into the Objective-C header | Objective-C knows nothing of them — Swift demands every parameter; provide explicit overloads |
Assuming a sealed class stays exhaustive for a switch in Swift | At the boundary it arrives as a set of ordinary classes — Swift asks for a default; expose a discriminator |
Reaching for @JvmOverloads in Kotlin/Native | It is a JVM annotation; on Native it does nothing |
| Treating Swift export as already the default or a stable path | Swift export is Alpha; the production path to iOS is the Objective-C header |
| Calling Kotlin/Wasm stable and taking it as a drop-in replacement for Kotlin/JS | Kotlin/Wasm is Beta; budget for a re-check on every update |
Thinking Ktor rewrites the network stack, and putting its engine in commonMain | Ktor delegates to a platform engine (OkHttp/Darwin), which is wired in by the platform source set |
Assuming kotlin-reflect is available in common code on every target | kotlinx.serialization is a compiler plugin: the KSerializer is generated at compile time, and no reflection is needed |
| Assuming KMP shares the UI by default | By default only the logic becomes shared; a shared UI is a separate, deliberate step through Compose Multiplatform |
| Agreeing to freeze delivery for the sake of a migration to KMP | The shared module is consumed as an ordinary dependency — both teams keep releasing |
Interview relevance
Kotlin Multiplatform is a topic where the interviewer probes not a feature list but an execution model: what physically happens when common code is built for someone else's platform. One question separates a junior from a middle — "what exactly can commonMain not do, and why"; and a middle from a senior — "what is lost when the shared module crosses the Objective-C boundary, and how do you live with that in production". The right answers are almost always about compile time: source sets are separate compilations, the compiler checks expect/actual, and a klib is IR awaiting lowering, not a binary. And they are almost always version-aware: freeze() was not "removed", it does not compile; Swift export is Alpha; Wasm is Beta.
Typical checks:
- How
commonMaindiffers from a platform source set, and where its boundary runs. - What
expect/actualdo, and why a missingactualis a compile error. - What a
klibis, why a KMP library does not publish a JVM jar, and why it is tied to the compiler version. - What became of the Kotlin/Native memory model, why
freeze()does not compile, and what protects shared state now. - How the shared module reaches iOS, and what is lost at the Objective-C boundary (default arguments,
sealed, generics). - The status of Kotlin/Wasm and of Swift export — and what "Beta"/"Alpha" practically mean for a release branch.
- Why
Ktor,kotlinx.serializationandSQLDelightwork fromcommonMainwhile a pure Java library does not. - Where the sharing boundary runs (logic versus UI), and how to adopt KMP without freezing delivery.
Common wrong answer: "KMP is like Flutter or React Native, only in Kotlin: you write a shared UI and it runs on both platforms." From there the candidate infers that there is a shared runtime and a bridge, and therefore that a shared screen is slower than a native one. In reality KMP shares the logic and compiles it into a native binary for every target — no VM, no bridge, no interpreter; the UI stays platform-specific by default, and sharing it through Compose Multiplatform is a separate and more expensive decision, not the default. The second classic failure is answering a Kotlin/Native question with a confident story about freeze() and object freezing: that model has been gone since 1.9.20, a freeze() call today simply does not compile, and shared state must be protected with a Mutex or by confining it to a single dispatcher — exactly as on the JVM.