skip to content

You ship a Kotlin/Native CLI and an iOS framework. What toolchain and build-performance characteristics of Kotlin/Native should drive your engineering decisions, and how do you mitigate the rough edges?

level: principalimportance: nice to knowfreq 22%

answer

  1. AOT/LLVM: heavy build-time, per target
  2. DEBUG fast vs RELEASE slow (full optimization)
  3. Apple targets need macOS host; CI per OS
  4. Compiler caches; commonMain + expect/actual
  5. iOS: static framework, lean export, XCFramework

basics

~10 s

Kotlin/Native compiles ahead-of-time with LLVM, which is slow and per-platform. Plan for long release builds, keep debug builds fast, cache aggressively, and don't expect the huge JVM library ecosystem.

solid answer

~50 s

Kotlin/Native uses an **LLVM-based AOT** pipeline, so the key constraints are: (1) **slow link/optimize steps**, especially `RELEASE` (full LLVM optimization) versus `DEBUG`; (2) **per-target compilation** — every declared target (`linuxX64`, `macosArm64`, `iosArm64`, `iosSimulatorArm64`…) is its own build; (3) **host limitations** — Apple targets must build on macOS; (4) a **smaller library ecosystem** than the JVM, with `expect/actual` needed to fill gaps. Mitigations: enable Kotlin/Native **compiler caches** for debug, prefer building only the targets you need locally, split iOS device/simulator targets, use **CI runners per host OS**, and consider a **static framework** (`isStatic`) to simplify iOS embedding. Keep heavy logic in `commonMain` so most code is compiled once conceptually and only re-emitted per target. Watch binary size and GC pause characteristics for the CLI; for the framework, manage the exported API surface (`export`, `binaryOptions`) to keep Swift headers lean and link time down.

go deeper

for a junior

Knows Kotlin/Native builds are AOT and that there are multiple targets to compile.

for a middle

Understands DEBUG vs RELEASE cost and that Apple targets need a macOS host.

for a senior

Plans target sets, compiler caches, expect/actual, static frameworks, and exported API size.

for a principal

Owns the build/CI strategy and cost trade-offs across CLI + framework, balancing build time, binary size, GC behavior, and ecosystem gaps.

## The pipeline shapes the constraints Kotlin/Native is **ahead-of-time (AOT)** via **LLVM**. Unlike the JVM (compile fast, JIT later), the heavy work happens at build time, and it happens **once per target**. That drives nearly every engineering decision. ## Build-performance realities - **DEBUG vs RELEASE.** `DEBUG` skips optimization and builds quickly; `RELEASE` runs full LLVM optimization and can be many times slower to link. Use `DEBUG` for local/dev and only build `RELEASE` for shipping. - **Per-target multiplication.** Each target in the `kotlin {}` block is a separate compile+link. An iOS app commonly needs `iosArm64` (device) and `iosSimulatorArm64` (simulator), plus maybe `iosX64`. More targets = more build time. - **Compiler caches.** Kotlin/Native supports **compiler caches** that speed up incremental debug builds of dependencies; ensure they're enabled and not invalidated. - **Host constraints.** You can only build Apple targets on a **macOS host** (Apple toolchain). Linux/Windows targets build on their respective hosts (or cross-compile where supported). CI typically needs a macOS runner for the framework. ## Ecosystem and code structure - The JVM's vast library ecosystem is **not** available; you rely on **Kotlin Multiplatform libraries** and OS interop. Where a multiplatform API doesn't exist, use the **`expect`/`actual`** mechanism: declare `expect` in `commonMain` and provide a native `actual`. - Keep most logic in **`commonMain`** so it's authored once; only platform glue is per-target. ## iOS framework specifics ```kotlin kotlin { listOf(iosArm64(), iosSimulatorArm64()).forEach { it.binaries.framework { baseName = "Shared" isStatic = true // simpler embedding, no dylib } } } ``` - A **static framework** (`isStatic = true`) links into the app binary, avoiding dynamic-embed steps. - Control the **exported API surface** with `export(...)` and keep it minimal — a large surface bloats generated Obj-C headers and slows Swift compile. - An **XCFramework** (`XCFramework()` helper) bundles device+simulator slices for distribution. ## CLI specifics - Watch **binary size** and **startup**; native binaries start fast (no JVM warm-up) but can be large. - Understand **GC pause** behavior under load; the GC is concurrent in recent versions but still worth profiling. ## Decision summary Favor fast DEBUG loops, minimal target sets locally, aggressive caching, host-appropriate CI, a lean exported API, and keeping logic in `commonMain`. Accept longer RELEASE builds as the cost of AOT and budget CI time accordingly.

  • Why might RELEASE builds be dramatically slower than DEBUG?
    RELEASE runs the full LLVM optimization and inlining pipeline; DEBUG skips most optimization, so linking is far cheaper.
  • Why do you usually declare both iosArm64 and iosSimulatorArm64?
    Device builds need arm64 for the phone; the simulator on Apple Silicon needs a separate arm64-simulator slice — they are distinct targets, often bundled as an XCFramework.
  • How does keeping logic in commonMain help build performance and maintenance?
    Shared code is authored once and only the necessary platform glue uses expect/actual; you avoid duplicating logic per target and shrink the platform-specific compile surface.

saying these in an interview costs you the question

  • Assuming Kotlin/Native build times match JVM incremental builds
  • Thinking you can build iOS frameworks on a Linux CI host
  • Expecting the full JVM library ecosystem to be available
  • Ignoring per-target build multiplication when adding targets
  • Exporting a huge API surface into the iOS framework without cost awareness

context