Kotlin/Native Memory, Performance, Binary Size and Debugging
|
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. |
A Kotlin/Native framework is not a thin wrapper around Swift. It carries its own runtime: a memory allocator, a
garbage collector, a scheduler for background work and a type system, all linked into your iOS app next to the
Swift runtime. Most days you never notice, because the integration is designed to be seamless. This page covers
the days you do notice: a screen that stutters during a collection, a Swift object that is never freed, an
.ipa that grew by several megabytes, a release build that takes twenty minutes, or a crash report full of
unresolved addresses.
The baseline is the one used across this section: Kotlin 2.4.20 and Xcode 26.4, the pair that the
Kotlin Gradle plugin is tested against (see
Gradle Configuration, the Multiplatform DSL and Compatibility).
Kotlin/Native targets and their tiers are described on
How Kotlin Multiplatform Compiles: Targets and Tiers.
Several features below are Experimental or Beta; each is labelled where it appears. Every example is a
slice of TrailMate Everywhere, the multiplatform version of the Android section’s
TrailMate app (package com.example.trailmate). Xcode, LLDB and Instruments
themselves are not re-taught: see Debugging with Xcode and LLDB
and Performance and Instruments.
The Memory Manager
Kotlin/Native uses a modern memory manager that is similar to the JVM and Go. Two properties define it. First, objects live in one shared heap that any thread can access. Second, a tracing garbage collector (GC) runs periodically and reclaims every object that is no longer reachable from the roots, such as local and global variables.
For anybody who learned Kotlin/Native before 2021, this is a different world. The old model forbade sharing
mutable objects between threads, and required freeze() to make an object shareable. That model is gone, and the
history is worth knowing only so that you can recognize and delete old code.
| Aspect | Legacy memory manager (removed) | Current memory manager |
|---|---|---|
Status |
Default until 1.7.20; the legacy manager and freezing were removed in 1.9.20. |
Default since 1.7.20; the only manager since 1.9.20. |
Object sharing between threads |
Only immutable, frozen graphs, or objects transferred between workers. |
Any object, from any thread, with no special API. |
|
Required, and a frequent cause of crashes. |
Not needed; the freezing API was removed in 1.9.20. |
Coroutines |
The |
Plain |
Global state |
|
Ordinary shared state; you protect it like on the JVM. |
The coroutine consequences of this change (dispatchers, thread confinement, atomics) are covered on Coroutines, Flow and Concurrency in Shared Code. == The Garbage Collector
The Kotlin/Native GC is still evolving. Today it is a concurrent mark and sweep (CMS) collector that does not split the heap into generations. It runs on its own thread and starts either from memory-pressure heuristics or from a timer. During marking, application threads, the GC thread and optional marker threads all help drain the mark queue, and by default the mark phase runs concurrently with the application, which shortens pauses. Weak references are also processed concurrently by default.
CMS is the default since Kotlin 2.4.0
Kotlin 2.0.20 introduced the concurrent collector as an experiment. After user feedback and regression fixes, CMS became the default in Kotlin 2.4.0. Before that, the default was parallel mark concurrent sweep (PMCS), which marks in parallel but stops the application while marking. The practical effect for an iOS app is shorter GC pauses and fewer dropped frames, which matters most for Compose Multiplatform screens (see Compose Multiplatform on iOS).
The collector is selected with the kotlin.native.binary.gc binary option:
| Value | Behavior | When to use it |
|---|---|---|
|
Concurrent mark and sweep; marking runs alongside application threads. |
Normal apps. Leave it alone. |
|
Parallel mark, concurrent sweep. |
Fallback if you hit a problem that appears only with CMS. |
|
Simple stop-the-world mark and sweep. |
Diagnosing whether concurrency is involved in a bug. |
|
No collection at all; memory only grows. |
Tests and very short-lived programs. Never ship it. |
# gradle.properties (project root of TrailMate Everywhere)
# The default since Kotlin 2.4.0 is already cms; this line is only needed to switch back.
kotlin.native.binary.gc=pmcs
Forcing and observing collections
You can start a collection by hand with kotlin.native.internal.GC.collect(). The call blocks until the
collection completes. Production code should not call it; its value is in tests, which can check that an object
graph really is released.
// sharedLogic/src/iosTest/kotlin/com/example/trailmate/MemoryLeakTest.kt
package com.example.trailmate
import kotlin.native.internal.GC
import kotlin.test.Test
import kotlin.test.assertEquals
class Resource
// A deliberately leaking global, to show what the test detects.
val leakyCache = mutableListOf<Resource>()
@OptIn(ExperimentalStdlibApi::class)
fun heapUsage(): Long {
GC.collect()
return GC.lastGCInfo!!.memoryUsageAfter["heap"]!!.totalObjectsSizeBytes
}
private fun allocateAndDrop() {
leakyCache.add(Resource())
// The test fails if you remove the next line, because the object is still reachable.
leakyCache.clear()
}
class MemoryLeakTest {
@Test
fun cacheDoesNotRetainResources() {
val before = heapUsage()
allocateAndDrop() // a separate function, so temporaries are out of scope
val after = heapUsage()
assertEquals(before, after)
}
}
To read the collector’s own log, add the compiler option -Xruntime-logs=gc=info. At the moment the logs are
printed to stderr. They report, among other things, the number of stable references in the root set, which is the
number to watch when you suspect the interop leaks described below.
The allocator
Kotlin/Native has its own allocator. It divides memory into pages tuned to allocation sizes, so that sweeping can
proceed page by page, and it protects against allocation spikes: if the application allocates garbage faster than
the GC thread can reclaim it, the collector forces a stop-the-world phase until the iteration completes. Two
Experimental binary options relate to it: pagedAllocator (default true; turning it off disables the Xcode
memory tracking described below) and latin1Strings (default false; stores strings in Latin-1 when every
character fits, which cuts string memory and binary size but makes String.pin, String.usePinned and
String.refTo less efficient).
Integration with Swift and Objective-C ARC
Kotlin uses tracing GC. Swift and Objective-C use automatic reference counting (ARC). Kotlin objects handed to Swift are wrapped in Objective-C objects that retain them; Swift objects handed to Kotlin are held through a stable reference that retains them until the Kotlin side lets go. Usually that is invisible. The edge cases are all consequences of one fact: a Swift object that crossed into Kotlin is released only after a GC cycle has collected the Kotlin side that referred to it.
Deinitializers: when and where deinit runs
Because a Swift object is released only after a collection, its deinit does not run when the last Swift
variable disappears. And because deinit can run arbitrary code, including Kotlin code, it cannot execute during
the GC pause itself. It runs afterwards, and the thread is predictable:
-
If the Swift object was passed to Kotlin on the main thread,
deinitruns on the main thread. -
If it was passed on another thread, or the main dispatch queue is not being processed,
deinitruns on a special GC thread. That thread is Objective-C-compatible: it has a run loop and drains autorelease pools.
The binary option objcDisposeOnMain (default true, available since 1.9.0) controls this. Setting
kotlin.native.binary.objcDisposeOnMain=false forces deinitialization onto the GC thread even for objects passed on
the main thread, which avoids main-thread work at the price of deinit code that must now be thread-safe.
// iosApp/iosApp/Diagnostics/ReleaseProbe.swift
import Foundation
import SharedLogic // the Kotlin framework (baseName = "SharedLogic")
final class ReleaseProbe: LocationCallback {
init() { print("init on \(Thread.current)") }
// Runs only after Kotlin releases it, never at the end of the scope that created it.
deinit { print("deinit on \(Thread.current)") }
func onLocation(latitude: Double, longitude: Double) {}
}
func probeOnce() {
let registry = LocationRegistry() // Kotlin class holding a callback
registry.register(callback: ReleaseProbe())
// "deinit" is not printed here; it appears after a later GC cycle collects `registry`.
}
The consequence for your Swift code is simple: never use deinit for anything that must happen promptly, such as
stopping a CLLocationManager, ending a Live Activity or cancelling a timer. Release those resources explicitly
from a .task or onDisappear, and treat deinit as a last-resort cleanup.
Retain cycles across the boundary
Tracing GC can reclaim cycles; ARC cannot. This asymmetry decides what happens when a cycle spans both worlds:
| Cycle contains | Can it be reclaimed? |
|---|---|
Only Kotlin objects |
Yes, the tracing GC collects the whole cycle once it is unreachable. |
Only Swift or Objective-C objects |
No, ARC leaks it unless you use |
Kotlin and Swift objects |
No. If even one Swift object is in the cycle, the whole graph leaks, and it is impossible to break the cycle from the Kotlin side. |
There is currently no tool that detects retain cycles in Kotlin/Native code automatically. The prevention rules
are the usual ARC rules, applied at the boundary. TrailMate’s SwiftUI screen observes a shared ViewModel and
registers a callback; the closure must not capture self strongly if the ViewModel holds the closure.
// iosApp/iosApp/Trails/TrailListObserver.swift
import Foundation
import SharedLogic
@MainActor
final class TrailListObserver: ObservableObject {
@Published private(set) var trails: [Trail] = []
private let viewModel: TrailListViewModel // Kotlin object; holds the callback below
init(viewModel: TrailListViewModel) {
self.viewModel = viewModel
// Wrong: `self.trails = $0` would capture self strongly, and the Kotlin ViewModel retains
// the closure, which forms a Swift -> Kotlin -> Swift cycle that nothing can reclaim.
viewModel.setOnTrails { [weak self] list in
self?.trails = list
}
}
func stop() {
viewModel.setOnTrails { _ in } // explicit teardown, do not rely on deinit
}
}
The higher-level patterns that avoid callbacks (SKIE, KMP-NativeCoroutines) are on Consuming Coroutines and Flow from Swift and SwiftUI.
Long-lived Objective-C objects and autoreleasepool
Objective-C objects that cross into Kotlin can outlive the iteration that created them, particularly in a long
loop. A growing "stable refs" count in the GC log is the sign. Wrapping the loop body in autoreleasepool from
Kotlin lets each iteration’s autoreleased objects drain at once.
// sharedLogic/src/iosMain/kotlin/com/example/trailmate/data/PhotoThumbnailer.kt
package com.example.trailmate.data
import kotlinx.cinterop.autoreleasepool
import platform.Foundation.NSData
// Each thumbnail touches several temporary Objective-C objects. Without the pool, a 2,000-photo
// import keeps all of them alive until the outer pool drains.
fun thumbnailAll(photos: List<NSData>, makeThumbnail: (NSData) -> NSData): List<NSData> {
val result = ArrayList<NSData>(photos.size)
for (photo in photos) {
autoreleasepool {
result += makeThumbnail(photo)
}
}
return result
}
Background state and App Extensions
By default the memory manager does not track whether the app is in the background, and it does not integrate with
App Extensions. Its timer-based collections therefore keep firing while the app is suspended or running as, say, a
widget. The Experimental binary option appStateTracking changes this: when enabled, timer-based GC is turned
off in the background and a collection runs only when memory consumption becomes too high.
# gradle.properties
# Experimental since Kotlin 1.7.20.
kotlin.native.binary.appStateTracking=enabled
Binary Options
Binary options configure the Kotlin/Native runtime and compiler output. They are not Kotlin compiler flags in the
usual sense; they are -Xbinary=name=value pairs. There are three ways to set them, from broadest to narrowest.
In gradle.properties the form is kotlin.native.binary.smallBinary=true, which applies to every Kotlin/Native binary in the build.
// sharedLogic/build.gradle.kts -- applies to one binary
kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "SharedLogic"
isStatic = true
binaryOption("smallBinary", "true")
binaryOption("bundleId", "com.example.trailmate.shared")
}
}
}
The documentation lists only the notable options, not all of them. The table below covers the ones relevant to an iOS app, with the status stated by the documentation.
| Option | Values | Effect | Status |
|---|---|---|---|
|
|
Selects the garbage collector. |
|
|
|
Reduces release binary size; effectively sets |
Experimental since 2.2.20 |
|
|
Emits stack canaries. |
Available since 2.2.20 |
|
|
Uses caches in release mode: faster builds, fewer optimizations. |
Experimental since 2.4.20 |
|
|
Where Swift |
Available since 1.9.0 |
|
|
Suspends timer-based GC while the app is in the background. |
Experimental since 1.7.20 |
|
|
Reports GC pauses as signposts for Instruments. |
Available since 2.0.20 |
|
strings |
Values for |
Available since 1.7.20 |
|
|
Adds file names and line numbers to exception stack traces. |
Experimental since 1.6.20 |
bundleId and the framework Info.plist
The three bundle* options fill the version keys of the Info.plist that the compiler generates for a framework.
Give the shared framework a unique reverse-DNS bundleId, distinct from the app’s own, so that App Store Connect
validation and tools that match frameworks by identifier do not see a collision. See
Kotlin docs — Build final native binaries.
sourceInfoType and readable exception traces
An uncaught Kotlin exception on iOS prints a stack trace. By default the frames carry addresses, not file names.
Setting sourceInfoType adds file locations and line numbers. coresymbolication uses Apple’s CoreSymbolication
and is only available on Apple targets; it is already enabled by default for macOS and Apple simulators in debug
mode. The option is Experimental, so test the effect on your release configuration before relying on it.
Static versus Dynamic Frameworks
The framework binary kind produces an Objective-C/Swift framework. By default it is dynamic; with
isStatic = true it is static. The choice affects launch time, size and integration, and it interacts with
how Xcode links the framework (see
Integrating Shared Code into iOS: Direct, SwiftPM and CocoaPods).
| Concern | Static framework (isStatic = true) |
Dynamic framework (default) |
|---|---|---|
Linking |
Merged into the app binary at link time. |
A separate |
Launch time |
No extra dylib to load. |
Each dynamic framework adds dyld work at launch. |
Several Kotlin frameworks in one app |
Risky: each static framework embeds its own copy of the Kotlin runtime, so duplicates accumulate. |
Also duplicates the runtime per framework, but the duplicates are isolated. |
Embedding |
Nothing to embed or sign separately. |
Must be embedded and signed by the app target. |
Typical use |
One umbrella framework shipped to the app. |
Frameworks shared between an app and its extensions. |
TrailMate Everywhere builds one umbrella framework, SharedLogic, and makes it static, so the app links the Kotlin runtime once. (If the widget extension also links it, the extension carries its own copy; that is a size cost to measure, not a bug.) The most important rule
is this: ship one Kotlin framework per app, not one per feature. If two Kotlin modules each produce their own
framework, each includes a copy of the runtime and of the shared libraries, and objects of the same Kotlin type
become different Swift types. Combine modules into one umbrella and use export(…) (on an api dependency) for the types Swift must see.
Prefer export for exactly the types Swift must see. The transitiveExport option exports every transitive
dependency of the exported libraries; the documentation recommends against it, because it disables dead-code
elimination in many cases and grows both compile time and binary size. Distribution as an XCFramework, which
replaces the old universal (fat) framework, is covered on
Publishing Apps.
Keeping the App Small
Kotlin/Native compiles the whole program with link-time optimization and removes code that cannot be reached, but three things work against it: a broad exported API, broad dependencies, and unoptimized builds.
| Practice | Why it helps |
|---|---|
Export only what Swift needs |
Every exported declaration, and everything it references, must survive dead-code elimination and appear in the generated Objective-C header. |
Avoid |
It pulls the transitive closure of the exported libraries into the binary. |
Use |
|
Enable |
Optimizes for size with |
Consider |
Halves the size of ASCII-heavy string data; it makes the |
Build with Xcode’s Release configuration and archive |
App thinning, bitcode-free slicing and compression happen in the archive step, so compare the |
Compilation Time
Native release builds are the slowest part of a KMP pipeline. Release is highly optimized, and a release binary takes an order of magnitude longer to produce than a debug one. Most speed-ups come from not building things you do not need.
| Tip | Detail |
|---|---|
Use the latest Kotlin |
Each release improves the Native compiler. Move to 2.4.20 before tuning anything else. |
Preserve |
The compiler downloads its toolchain and caches results there. In containers or CI, cache it or redirect it with the |
Build one target and one build type while developing |
Run |
Do not use release tasks in the edit loop |
Replace |
Enable Gradle caches |
|
Try release caches (Experimental) |
|
Try incremental klib compilation (Beta) |
|
Do not export too much |
Every exported module costs compile time and size; avoid huge classes as well. |
In CI the biggest win is caching ~/.konan and the Gradle caches between runs; the workflow is shown on
CI/CD for Kotlin Multiplatform with GitHub Actions.
Time at least the second build, since the first is always slow. Release caches are a trade: you
choose build time over a fraction of the optimization, so use them on pull-request builds and turn them off for the
build you submit to the App Store.
Debugging Kotlin in Xcode and LLDB
The Kotlin/Native compiler emits DWARF 2 debug information, which LLDB understands, so you can set
breakpoints, step, and inspect variables. Because DWARF 2 has no identifier for Kotlin, the debugger treats Kotlin
as C89; that explains why names such as kfun:… and ObjHeader * appear in LLDB output.
Breakpoints and stepping
Xcode can set a breakpoint in a Kotlin source file only when the Kotlin sources are visible to it. The Kotlin Multiplatform IDE plugin can debug an iOS run configuration from IntelliJ IDEA or Android Studio, with Kotlin frames and Swift frames in one stack; use Xcode when you need Instruments, view debugging, or on-device testing (see Getting Started and Tooling). In an LLDB console you can set Kotlin breakpoints several ways.
# LLDB console, attached to the TrailMate iOS app
(lldb) b -n kfun:com.example.trailmate.data.OfflineFirstTrailRepository#refresh(){}
(lldb) b -f OfflineFirstTrailRepository.kt -l 42
(lldb) b -a 0x00000001000012e4
(lldb) b -r OfflineFirstTrailRepository
(lldb) fr var
(lldb) v trail->name
By name uses the compiled symbol (-n is the default); by location uses file and line; by address is useful in
disassembly; and a regular expression is handy for generated lambdas, whose names contain #. Variables declared
with var and val can be inspected with fr var or v; Kotlin objects appear as ObjHeader * with the
fields reachable by dot or arrow notation.
The documented limits: expression evaluation in the debugger is not supported, and there are no plans to implement it. Python bindings are also slow. Expect to log values or use watch expressions on plain variables instead of calling Kotlin functions from the console.
Crash Reports and dSYM Symbolication
A crash report from a user lists memory addresses. Symbolication translates them back to function names and line
numbers, using a .dSYM file. For Kotlin code that file comes from the Kotlin/Native compiler, which generates
.dSYM bundles for release (optimized) binaries on Apple platforms by default. Xcode looks for them in the
standard locations and uses them automatically, including in projects created from the IntelliJ IDEA templates.
On non-Apple platforms, or if you need lighter debug information in a binary, the -Xadd-light-debug compiler
option adds it to the binary and increases its size.
The workflow for a release is:
-
Archive in Xcode. The archive contains the app and its
.dSYMbundles, including the Kotlin framework’s. -
Keep the archive (or at least the dSYMs) for every build you ship. A crash report can only be symbolicated with the dSYM of that exact build.
-
When uploading to App Store Connect, leave "Upload your app’s symbols" enabled so that Xcode Organizer and crash services can symbolicate. Third-party crash reporters need the dSYMs uploaded separately.
-
To symbolicate manually, drag the crash log into Xcode’s Devices and Simulators window, or use
atoswith the dSYM. Xcode Organizer’s Crashes tab does it automatically for TestFlight and App Store crashes.
The Apple-side tooling is described on
Logging, Metrics and Crash Reports. An unhandled Kotlin
exception that propagates into Swift terminates the process, because Swift cannot catch it unless the Kotlin
function declares @Throws; the crash log then shows a Kotlin stack, and symbolication is the difference
between 0x1032f4 and OfflineFirstTrailRepository.refresh.
Profiling with Instruments
Xcode Instruments profiles a Kotlin framework like any other native code, because the Kotlin frames are ordinary native frames. Run Product, Profile (Cmd+I) on a release-like configuration, and use the standard templates: Time Profiler for CPU, Allocations and Leaks for Objective-C and Swift objects, and VM Tracker for virtual memory. The general workflow is on Performance and Instruments; here are the Kotlin-specific parts.
GC pauses as signposts
To see whether a visible hitch coincides with a collection, enable safepoint signposts. This binary option
(enableSafepointSignposts, available since 2.0.20) makes the GC report each pause as a signpost event.
# gradle.properties
kotlin.native.binary.enableSafepointSignposts=true
Then, in Xcode, choose Product, Profile, pick the os_signpost template, set the subsystem to
org.kotlinlang.native.runtime and the category to safepoint, and record. Every blue blob on the lowest graph is
a GC pause; if one lines up with a hang in the Hangs instrument, the GC is involved, and you can reduce allocation
rate in the hot path (for example, avoid building a new List<Trail> on every scroll event).
Tracking Kotlin memory in VM Tracker
On Apple platforms, Kotlin’s share of memory is tagged with an identifier (mmapTag, default 246), so VM Tracker can
show how much is reserved by Kotlin. The feature is available only with the default allocator, with tagging on
(mmapTag not 0) and with the paged allocator enabled (pagedAllocator not false); and it can be tuned if 246
collides with another tag in your app. This is the first place to look when the total footprint is much larger than
your Kotlin heap statistics suggest.
C Interop Basics
Kotlin/Native can call C libraries. The cinterop tool reads C headers and generates Kotlin stubs for functions,
structs and constants. C library import is Beta: every declaration that cinterop generates carries the
@ExperimentalForeignApi annotation, so you must opt in. Apple’s own frameworks (Foundation, UIKit, POSIX) are
different: they come as pre-built platform libraries that need no setup, and only some of their APIs require the
opt-in. For TrailMate you almost always want the platform libraries, which are described on
expect/actual and Calling Platform APIs.
The workflow
-
Write a definition (
.def) file that tells cinterop which headers to include. -
Add the
cinteropblock to the Kotlin target in Gradle. -
Call the generated bindings from
iosMain(ornativeMain).
# sharedLogic/src/nativeInterop/cinterop/geohash.def
headers = geohash.h
headerFilter = geohash.h
package = com.example.trailmate.cinterop.geohash
compilerOpts = -I/Users/dev/trailmate/native/include
linkerOpts = -L/Users/dev/trailmate/native/lib -lgeohash
// sharedLogic/build.gradle.kts
kotlin {
iosArm64 {
compilations.getByName("main") {
cinterops {
val geohash by creating {
definitionFile.set(project.file("src/nativeInterop/cinterop/geohash.def"))
}
}
}
}
}
The paths in compilerOpts and linkerOpts above are placeholders; in a real project they point to your own
vendored library, ideally relative to the project. Dependency management for such libraries, including CocoaPods and
SwiftPM import, is covered on
Dependencies, klibs.io, cinterop and SwiftPM Import.
Memory in C interop
Memory that C owns is outside the GC. The Kotlin API therefore makes ownership explicit: memScoped allocates
native memory that is freed when the block ends, and usePinned pins a Kotlin array so that C can read it without a
copy. A pointer must never be kept after the block that created it.
// sharedLogic/src/iosMain/kotlin/com/example/trailmate/geo/GeoHash.kt
package com.example.trailmate.geo
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.allocArray
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.toKString
import kotlinx.cinterop.ByteVar
import com.example.trailmate.cinterop.geohash.geohash_encode
@OptIn(ExperimentalForeignApi::class)
fun encodeGeoHash(latitude: Double, longitude: Double, precision: Int = 9): String =
memScoped {
val buffer = allocArray<ByteVar>(precision + 1) // freed when memScoped ends
geohash_encode(latitude, longitude, precision, buffer)
buffer.toKString() // copy to a Kotlin String before returning
}
geohash_encode is a function of the example C library; TrailMate does not ship it.
Common Mistakes
-
Copying legacy concurrency code. Remove
freeze(),@SharedImmutable,@ThreadLocalworkarounds andnative-mtcoroutine versions; none are needed. -
Trusting
deinit. It runs after a GC cycle, possibly on a GC thread. Release real resources explicitly. -
Shipping several Kotlin frameworks. Each one duplicates the runtime; build one umbrella framework.