How KMP Compiles: Kotlin/JVM, Kotlin/Native, Kotlin/JS and Kotlin/Wasm

This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Kotlin Multiplatform (KMP) does not have one compiler that emits one universal binary. It has one frontend that understands your Kotlin source and several backends that each generate code for a different runtime: the JVM, native machine code through LLVM, JavaScript, and WebAssembly. Knowing which backend produces which artifact explains most of the behaviour you will meet later: why some APIs exist only on some targets, why iOS builds need a Mac, why a library publishes several variants, and why a clean build takes minutes.

This page targets the section baseline: Kotlin 2.4.20 with the K2 compiler (the only compiler in current releases), Xcode 26.4 for Apple targets, and Compose Multiplatform 1.12.1 where UI is mentioned. Stability is stated wherever it is not Stable: Kotlin/Wasm is Beta, watchOS and tvOS are Beta, and the Kotlin/Native target-tier system described below is a support policy, not an API. The examples slice the running example, TrailMate Everywhere (package com.example.trailmate), introduced in What Kotlin Multiplatform Is and When to Use It. The Kotlin language itself is not re-taught; see the Kotlin Reference and, for the compiler and Gradle plugin from the language side, Kotlin: Build and Tooling.

The Pipeline at a Glance

Diagram of Kotlin Multiplatform compilation. On the left the commonMain source set and the platform source sets androidMain/jvmMain

The pipeline has four stages:

  1. Source sets are grouped per target. The Gradle plugin decides which source sets feed which compilation (see Project Structure, Targets, Source Sets and the Hierarchy Template). The jvm() target compiles commonMain + jvmMain; iosArm64() compiles commonMain + nativeMain
    appleMain + iosMain + iosArm64Main, and so on.

  2. The K2 frontend analyses the code. It builds a tree (FIR, the frontend intermediate representation), resolves names and types, checks expect/actual pairs, and lowers the result to Kotlin IR.

  3. A backend generates target code. Each backend consumes IR and emits its own format.

  4. Gradle links and packages. Native code is linked into frameworks or executables; JS is bundled by webpack; Wasm gets a loader; JVM classes go into a JAR (or, for Android, are turned into DEX by the Android toolchain).

The K2 Frontend and Kotlin IR

K2 is the Kotlin compiler frontend that became the default in Kotlin 2.0 and is the only frontend in 2.4.20. It matters for multiplatform for three reasons:

  • One analysis model for all targets. Earlier compilers had target-specific frontends with different behaviour; K2 analyses common code once with a shared front end, so a common source set means the same thing on every target.

  • Faster builds. K2 is considerably faster than the old frontend, which matters because multiplatform builds repeat compilation per target.

  • Compiler plugins on IR. Compose, kotlinx.serialization, Mokkery and others are compiler plugins that transform IR, so they work on every backend that supports IR plugins. Their Gradle plugin versions follow the Kotlin version (for example org.jetbrains.kotlin.plugin.compose and org.jetbrains.kotlin.plugin.serialization).

The IDE’s Kotlin Multiplatform plugin requires K2 mode, so what the editor shows is what the compiler checks. Ask Gradle for a verbose build to see which compilation tasks run per target:

# run from the TrailMate Everywhere root
./gradlew :sharedLogic:tasks --group=build
./gradlew :sharedLogic:compileKotlinJvm :sharedLogic:compileKotlinIosSimulatorArm64 --dry-run

See Kotlin docs — Kotlin Multiplatform for the overview of how shared code is compiled for each target.

The Four Backends

Backend Input and output Runtime Typical artifact

Kotlin/JVM

IR to JVM bytecode (.class files)

JVM 8+; on Android the bytecode is converted to DEX by D8/R8

.jar, .aar, .apk/.aab after packaging

Kotlin/Native

IR to .klib, then LLVM to machine code

No VM; a native binary with the Kotlin runtime (memory manager, garbage collector) linked in

.framework, XCFramework, .kexe, .so/.dll/.a

Kotlin/JS

IR to JavaScript (ES modules, ES2015+) with optional TypeScript declarations

Browser or Node.js

.js, .d.ts, npm package

Kotlin/Wasm

IR to WebAssembly using the WasmGC proposal, plus a JS loader

Browser or Node.js with WasmGC (Beta); WASI for server-side use

.wasm, .mjs

A common beginner assumption is that Kotlin/Native "translates Kotlin to Swift" or "interprets Kotlin on iOS". Neither is true: Kotlin/Native compiles to ordinary machine code and the Swift or Objective-C side only sees an exported API header. The relationship is covered in Objective-C, Swift and Kotlin Interop.

Kotlin/JVM: Android, Desktop and Server

The JVM backend is the oldest and most mature. It emits standard bytecode, so everything from the Java ecosystem works: JVM libraries, reflection, JNI, and Java interop. In a multiplatform project three kinds of target use it:

  • jvm() for desktop (Compose Multiplatform for Desktop) and server code (for example a Ktor server).

  • android { } from the Android-KMP library plugin: the same bytecode, then DEX through the Android toolchain. Android-specific configuration is in The Android Target: the Android-KMP Library Plugin and AGP 9.

  • androidApp, a separate application module that is not a KMP module under AGP 9 and merely depends on the shared code.

// sharedLogic/build.gradle.kts
plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidKotlinMultiplatformLibrary)
}

kotlin {
    jvm()                       // desktop and server consumers

    android {                   // com.android.kotlin.multiplatform.library
        namespace = "com.example.trailmate.shared"
        compileSdk = 37
        minSdk = 26
    }

    sourceSets {
        commonMain.dependencies {
            implementation(libs.kotlinx.coroutines.core)
        }
    }
}

See Kotlin docs — Supported platforms for the platforms the JVM backend serves in KMP.

The JVM target compiles commonMain with jvmMain, so a platform API such as java.util.UUID is only available in jvmMain and androidMain, never in commonMain. Common code sees only the Kotlin standard library and multiplatform libraries, which is exactly what lets the same file also compile for iOS.

Kotlin/Native: LLVM, Frameworks and Executables

Kotlin/Native compiles Kotlin to native binaries that run without a virtual machine. Internally it first produces a .klib (a library of serialized IR, described below) and then uses an LLVM-based toolchain to turn IR into machine code for the chosen target. What ends up in the binary:

  • your compiled code and the code of all dependencies, with dead code removed by dead-code elimination;

  • the Kotlin/Native runtime: the memory manager and garbage collector (the new memory manager has been the only one since 1.9.20; the concurrent mark and sweep collector is default since 2.4.0). See Kotlin/Native Memory, Performance and Debugging;

  • bindings to the platform: for Apple targets, platform libraries that expose Foundation, UIKit and other system frameworks to Kotlin without any setup (see expect/actual and Calling Platform APIs).

You choose the kind of binary in the binaries block of a native target. For iOS the typical output is a framework that Xcode links; for desktop or CLI tools it is an executable.

// sharedLogic/build.gradle.kts (Apple part)
import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework

kotlin {
    val xcf = XCFramework("SharedLogic")

    listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
        target.binaries.framework {
            baseName = "SharedLogic"   // the Swift module name
            isStatic = true            // static frameworks avoid embedding issues in Xcode
            xcf.add(this)
        }
    }

    // A command-line executable instead of a framework (illustrative, Apple silicon Mac host)
    macosArm64 {
        binaries.executable {
            entryPoint = "com.example.trailmate.cli.main"
        }
    }
}

See Kotlin docs — Kotlin/Native for binary kinds and platform libraries.

Gradle names the link tasks from the build type, binary kind and target, for example linkDebugFrameworkIosSimulatorArm64, linkReleaseFrameworkIosArm64, or assembleSharedLogicXCFramework for the combined artifact. On the Swift side the framework is imported by its baseName:

// iosApp/iosApp/PlatformLabel.swift
import SharedLogic   // the baseName set in the Gradle binaries block

func platformLabel() -> String {
    // top-level Kotlin function platformName() in Platform.ios.kt
    return Platform_iosKt.platformName()
}

An XCFramework bundles the framework slices for several architectures and SDKs (device and simulator) in one folder; it replaces the legacy "fat" universal frameworks, which cannot combine two slices of the same platform such as arm64 for both device and simulator. How Xcode consumes it, and the SwiftPM and CocoaPods routes, are compared in iOS Integration Options.

.klib: the Library Format

A .klib is the library format shared by Kotlin/Native, Kotlin/JS and Kotlin/Wasm. It is an archive holding serialized Kotlin IR, metadata and resources; it is not machine code, which is why a klib can be compiled further for any target of its family. When you depend on a multiplatform library such as Ktor or kotlinx.coroutines, Gradle resolves one variant per target: a JAR for JVM and Android, and a klib for each native, JS and Wasm variant. A library publishes a klib per target plus Gradle module metadata that links them (covered in Dependencies, klibs.io, cinterop and SwiftPM Import).

Practical consequences:

  • A klib built for iosArm64 cannot be used for linuxX64: the klib is target-specific for Native, because the IR contains platform-library references and actual implementations chosen for that target.

  • Shared metadata klibs exist for intermediate source sets (for example appleMain), so the IDE can analyse code that targets a group of platforms. They are for analysis; your binary is built from the per-target compilation.

  • A klib is consumed by the linker, so a library with no binary of its own (a shared module without a binaries { framework {} } block) produces only klibs, not a framework.

Kotlin/JS

Kotlin/JS compiles IR to JavaScript with the JS IR compiler, the only supported JS backend. It emits ES modules and can target ES2015 and newer; the older ES5-only output of legacy compilers is gone. Output goes to the browser (bundled with webpack by the Gradle plugin) or to Node.js. Kotlin/JS is Stable as a KMP web target.

// webApp/build.gradle.kts (Kotlin/JS variant, instead of wasmJs)
kotlin {
    js {
        browser()                       // bundle for the browser; nodejs() targets Node
        binaries.executable()           // produce a runnable bundle
        generateTypeScriptDefinitions() // emit .d.ts for @JsExport declarations
    }
}

Only declarations annotated with @JsExport are visible to JavaScript or TypeScript consumers; everything else may be renamed or removed by the optimizer. This is the same idea as limiting the exported API to Swift, and it keeps output small. In Kotlin/JS, Long is a Kotlin class rather than a JS number, so types crossing the boundary must be chosen deliberately.

Kotlin/Wasm

Kotlin/Wasm compiles IR to WebAssembly using the WasmGC proposal, so the browser’s garbage collector manages Kotlin objects. It is Beta. Compose Multiplatform for web is built on the wasmJs target and is also Beta (since CMP 1.9). The compiled output is a .wasm module plus a JavaScript loader; it runs in browsers with WasmGC support. There is also a wasmWasi target for WASI runtimes outside the browser; it is far less used.

// webApp/build.gradle.kts
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl

kotlin {
    @OptIn(ExperimentalWasmDsl::class)
    wasmJs {
        browser()
        binaries.executable()
    }
}

Choosing between the two web targets is a trade-off. A project can declare both and share a webMain source set between them; the details, browser support and CMP for web are in Web with Kotlin/Wasm and Kotlin/JS.

Kotlin/JS Kotlin/Wasm

Stability (2.4.20)

Stable

Beta

Output

JavaScript (ES modules)

WebAssembly (WasmGC) plus JS loader

Browser support

Every modern browser

Browsers with WasmGC support

Runs Compose Multiplatform for web

Compatibility target, not the primary path

Primary path (Beta)

JS interop

Direct, dynamic; @JsExport for consumers

Typed external declarations, explicit conversions

How Common Code Is Compiled Once Per Target

Common code is not "compiled once and run everywhere". It is analysed once and compiled once per target. A line in commonMain is compiled again for jvm, android, iosArm64, iosSimulatorArm64, wasmJs and every other declared target. That is why:

  • Adding a target multiplies compilation time. Declaring only the targets you ship (and not for example linuxX64 "just in case") is the cheapest optimization.

  • The same Kotlin source can behave differently on different runtimes: Float.toString() formatting, regular expression syntax details, Long representation on JS, and string normalization all depend on the platform’s implementation. Test on every target (see Testing).

  • A compile error that appears for only one target usually means the code relied on an API absent there.

expect/actual is the mechanism that lets common code call something each target implements differently. The expect declaration is checked in common code, and each target’s compilation must find a matching actual.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/Platform.kt
package com.example.trailmate

expect fun platformName(): String

fun greeting(): String = "Welcome to TrailMate on ${platformName()}"
// sharedLogic/src/iosMain/kotlin/com/example/trailmate/Platform.ios.kt
package com.example.trailmate

import platform.UIKit.UIDevice

actual fun platformName(): String =
    UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
// sharedLogic/src/jvmMain/kotlin/com/example/trailmate/Platform.jvm.kt
package com.example.trailmate

actual fun platformName(): String = "JVM ${System.getProperty("java.version")}"

See Kotlin docs — Expected and actual declarations for the rules. Because the compiler finds UIDevice only in the Apple compilations, the iosMain file cannot accidentally leak into jvmMain.

The Kotlin/Native Target List and Support Tiers

Kotlin/Native supports many operating systems and CPU architectures. Each is a target with a Gradle function that you call in the kotlin {} block. Kotlin groups them into support tiers that describe how much testing and stability the team commits to. Tier 1 targets are tested on CI and keep source and binary compatibility between compiler releases; Tier 2 targets are tested regularly and are expected to work; Tier 3 targets are not guaranteed to be tested and may fail to compile or run in a given release, with no promise of compatibility between releases.

Tier Targets Typical use

Tier 1

macosArm64, iosSimulatorArm64, iosArm64

iPhone, iPad and Apple-silicon Mac development

Tier 2

linuxX64, linuxArm64, watchosSimulatorArm64, watchosArm64, tvosSimulatorArm64, tvosArm64

Linux servers and CLIs; Apple Watch and Apple TV (Beta product platforms)

Tier 3

iosX64, mingwX64, androidNativeArm32, androidNativeArm64, androidNativeX86, androidNativeX64

Intel simulators, Windows native, Android NDK-only code

Deprecated

macosX64, tvosX64, watchosX64 (deprecated in 2.3.20); watchosArm32 (deprecated in 2.4.20)

Intel Macs and old watches; migrate away

How to apply the table:

  • For an iPhone app you need iosArm64() (devices) and iosSimulatorArm64() (simulators on Apple silicon Macs). Both are Tier 1. iosX64() is only needed for the Intel simulator and is Tier 3.

  • The old ios() shortcut was removed in 2.2.0; declare the concrete targets and let the default hierarchy template create iosMain.

  • A deprecated target still compiles but emits a warning, and will eventually be removed; do not add one to a new project.

  • Google’s own support tiers for KMP libraries differ and are about Jetpack libraries, not about Kotlin compilation: Android, JVM and iOS are its Tier 1, macOS and Linux Tier 2, and watchOS, tvOS, Windows, JS and Wasm Tier 3. Do not confuse them with Kotlin/Native tiers.

// sharedLogic/build.gradle.kts
kotlin {
    iosArm64()             // Tier 1: physical devices
    iosSimulatorArm64()    // Tier 1: simulator on Apple silicon

    // iosX64()            // Tier 3: only if you still need the Intel simulator
    // macosX64()          // deprecated: Intel Mac target
}

See Kotlin docs — Kotlin/Native target support for the authoritative, current list.

Apple Minimum OS Versions

Kotlin/Native compiles for a minimum deployment OS. Its defaults are iOS 15 and macOS 12; code compiled against these does not call anything that needs a newer OS unless the code itself requests it. Your Xcode project sets its own deployment target, and the effective minimum for the app is the higher of the two. If the Xcode target is lower than what the Kotlin framework needs, the linker or the App Store upload may complain, so keep them aligned. CMP itself requires iOS 14 (see Compose Multiplatform on iOS).

# iosApp/iosApp.xcodeproj (build settings; shown as an xcconfig excerpt)
IPHONEOS_DEPLOYMENT_TARGET = 16.0
MACOSX_DEPLOYMENT_TARGET = 13.0

See Kotlin docs — Kotlin/Native target support for the default minimum versions, and Xcode Projects and Previews for where deployment targets are set in Xcode.

Host Requirements: What Can Build What

The compiler itself runs on the JVM, but linking and Apple SDKs set limits. The rule worth memorising is: Apple binaries can only be built on macOS. Frameworks, XCFrameworks and Apple native executables need Xcode and its SDKs. Kotlin can still compile other targets on any host.

Target family macOS Linux Windows

JVM and Android (jvm, android)

Yes

Yes

Yes

Kotlin/JS and Kotlin/Wasm

Yes

Yes

Yes

Apple (ios*, macos*, watchos*, tvos*)

Yes (Xcode required)

No

No

Linux native (linuxX64, linuxArm64)

Yes

Yes

Yes (cross-compile)

Windows native (mingwX64)

Yes

Yes

Yes

Two consequences matter for teams. First, a Windows or Linux developer can build and test everything except the Apple targets; the Kotlin Gradle plugin skips Apple targets on unsupported hosts with a warning rather than failing the build. Second, publishing a multiplatform library needs a macOS host to produce the Apple variants; otherwise the published module lacks them. CI therefore commonly builds on a macOS runner (or a macOS-plus-Linux matrix). See Publishing Multiplatform Libraries and CI/CD with GitHub Actions.

Which Targets Does Your Project Need?

Project Declare these targets Notes

Mobile app, shared logic only

android { }, iosArm64(), iosSimulatorArm64()

Add jvm() only if a server or desktop shares the module

Mobile app with Compose Multiplatform

The same, plus jvm() for desktop and wasmJs() for web if shipped

CMP Android, iOS and desktop are Stable; web is Beta

Desktop app

jvm()

Compose Multiplatform for Desktop; Tier 1 equivalent maturity

Web app

wasmJs() (Beta) or js() (Stable)

Optionally both, sharing webMain

Server (Ktor)

jvm()

Optionally linuxX64() for a native binary

Multiplatform library

Every target the library supports; at minimum JVM, Android, iOS

Libraries should cover more targets than apps

The TrailMate Everywhere sharedLogic module declares jvm(), android, iosArm64(), iosSimulatorArm64() and wasmJs(), because the Android, iOS, desktop and web apps all consume it.

Common Mistakes

  • Calling a platform API from commonMain. java.io.File, UIDevice or window fail on other targets; put the call behind expect/actual or an interface.

  • Declaring iosX64() by default. It is Tier 3; modern Macs run the Arm simulator, so iosSimulatorArm64() is the target you need.

  • Using the removed ios() shortcut from old tutorials; declare the concrete targets.

  • Adding deprecated targets (macosX64, tvosX64, watchosX64, watchosArm32).

  • Expecting to build iOS on Linux or Windows. Use a macOS runner for Apple targets.

  • Assuming Beta targets are production-stable. Kotlin/Wasm and CMP for web are Beta; watchOS and tvOS are Beta.

  • Mixing static and dynamic frameworks carelessly. Prefer one framework (isStatic = true) exporting the shared API, not one per module.

  • Omitting the baseName. The Swift import name comes from baseName, not the module directory.

  • Treating a .klib as a distributable library for Swift. Swift consumes frameworks or XCFrameworks.