| name | kmp-performance |
| description | Performance tuning for Kotlin Multiplatform — coroutines on iOS, the new memory model, cold-start cost of the shared framework, and avoiding common Kotlin/Native footguns. Use when profiling a KMP app that feels slow on iOS. |
KMP Performance
Instructions
Most KMP performance problems surface on iOS because Kotlin/Native compiles AOT to a large framework binary. Fix them in this order: cold start → memory model misuse → dispatcher misuse → allocation hot paths.
1. Cold start
The iOS framework binary is loaded on app launch. Large exported API surfaces and heavy top-level initializers pay at startup.
- Shrink exported symbols. Only export composition roots. Everything else
internal.
- Avoid work in top-level
val/object initializers. Kotlin/Native runs them eagerly on first access; with a large dependency graph they cascade. Use by lazy (thread-safe by default).
- Do not initialize Koin / HTTP / DB at framework load. Wire those on first screen.
- Measure with Instruments → App Launch, and Xcode's
DYLD_PRINT_STATISTICS=1.
2. The new memory model (default since Kotlin 1.9)
The old "freezing" model is gone. Shared mutable state across threads is legal but should still be minimized:
- Prefer
StateFlow/SharedFlow for cross-thread communication.
- Use
kotlinx.atomicfu for atomic counters; it compiles to real atomics on all targets.
- Do not add
@SharedImmutable/freeze() — they're deprecated and do nothing useful.
If you see "InvalidMutabilityException" in logs, you are on the legacy MM. Force the new one:
# gradle.properties
kotlin.native.binary.memoryModel=experimental # already default; harmless belt-and-braces
3. Dispatchers on iOS
Dispatchers.Main maps to the UIKit main queue — use it only for UI updates.
Dispatchers.Main.immediate — dispatches synchronously if already on main. Prefer it for state-flow collectors feeding SwiftUI.
Dispatchers.IO exists on Kotlin/Native (coroutines 1.7+) and uses a bounded thread pool. Use for file / socket / blocking work. Do not reach for newSingleThreadContext { } except for strictly-serial state.
Dispatchers.Default uses the standard pool for CPU work.
Common mistake: doing all work on Dispatchers.Main because "Swift seems fine" — the main thread serializes with UIKit drawing and causes jank. Always withContext(Dispatchers.IO) { repo.load() }.
4. Hot-path allocations
Kotlin/Native's GC is generational but pauses more than Android ART on large heaps. Reduce allocations in tight loops:
- Prefer
buildList { } over repeated +.
- For numeric arrays, use
IntArray/FloatArray instead of List<Int>.
- Avoid
String.format on hot paths — it allocates heavily on iOS.
- Cache
Regex, Json, and DateTimeFormatter instances at top-level by lazy.
5. Flow performance
flowOn(Dispatchers.Default) upstream expensive maps — otherwise they run on the collector.
stateIn(SharingStarted.WhileSubscribed(5_000)) deduplicates subscriptions across screens.
distinctUntilChanged() on flows whose upstream emits unchanged values.
- Do not
.collect { } inside a LaunchedEffect(Unit) that keys on Unit if the flow depends on a changing parameter — you'll leak subscriptions.
6. Serialization & networking
Json { ignoreUnknownKeys = true; explicitNulls = false } at a single top-level instance.
- Ktor's
ContentNegotiation reuses the Json — don't re-create per call.
- For very large responses, consider
Json.decodeFromStream on JVM or Json.decodeFromBufferedSource with kotlinx-io. Avoid reading the full body into String first.
7. SQLDelight
- Put queries on
Dispatchers.IO: .asFlow().mapToList(Dispatchers.IO).
- Use
transaction { } for multi-write paths — a single transaction is orders of magnitude faster on iOS WAL.
- Index columns you
WHERE on; EXPLAIN QUERY PLAN works via the CLI.
8. Profiling
- Android: Android Studio CPU Profiler + Perfetto.
- iOS: Xcode Instruments — Time Profiler for CPU, Allocations for GC churn, Network for Ktor traffic.
- Shared benchmarks:
kotlinx-benchmark for commonMain microbenchmarks that run on JVM + Native.
Checklist