Engineering

Kotlin/Native memory is not the JVM's, and a retain cycle crosses the bridge

Kotlin objects held by Swift are ARC-counted. A loop through both runtimes is invisible to both profilers.

MobileByteSensei 8 min read
kmpiosmemory

TL;DR

On the JVM, a cycle between two objects is collected — the tracing collector sees that neither is reachable from a root. On iOS, a Kotlin object handed to Swift is ARC-counted, and ARC cannot collect cycles.

So this leaks: Swift holds a Kotlin object, passes a Swift closure into it, and the Kotlin side retains that closure. Two references, one loop, across two runtimes. Xcode's memory graph shows the Swift half and a KotlinBase it cannot see into; Kotlin's own tooling does not see the Swift half at all.

The fix is the one Swift engineers already know — break the cycle with a weak reference — applied on whichever side owns the closure. The hard part is seeing it.

The case this post is about

A shared ViewModel exposing state to SwiftUI via a callback, which is the ordinary shape:

// commonMain — exported to iOS
class SearchViewModel {
    private var onState: ((SearchStateView) -> Unit)? = null

    fun observe(listener: (SearchStateView) -> Unit) {
        onState = listener                         // ← retains the Swift closure
    }

    fun onQueryChanged(q: String) { … ; onState?.invoke(state.asView()) }
}
// SearchScreen.swift
@MainActor final class SearchModel: ObservableObject {
    @Published var state: SearchStateView = .idle
    private let vm = SearchViewModel()              // ← Swift retains the Kotlin object

    init() {
        vm.observe { state in
            self.state = state                      // ← the closure captures self, strongly
        }
    }
}

Correct-looking on both sides. SearchModel holds vm; vm holds a closure that holds SearchModel. On Android the equivalent code is collected the moment the ViewModel goes out of scope. On iOS it never is.

The symptom

Memory that climbs with navigation and never comes down:

Instruments → Allocations, pushing and popping the search screen

push 1   persistent 18.2 MB
push 5   persistent 41.7 MB
push 10  persistent 72.4 MB    ← nothing released on pop

Xcode's memory debugger shows the leaked SearchModel, which is where the investigation stalls. The graph looks like this:

SearchModel  (1 strong reference)
  └── from: KotlinBase  ← opaque. no field names, no path, no onward edges

The cycle is right there and the tool shows you one half of it. KotlinBase is the Obj-C base class every exported Kotlin object inherits; from Xcode's perspective it holds something, and its internals are not Obj-C properties, so there is nothing to expand.

Going the other way is no better. Kotlin's own leak tooling can tell you the lambda is retained by the ViewModel — which is true, intended, and not the leak. The thing that makes it a cycle is the Swift capture, which Kotlin cannot see.

Why the obvious fix failed

"Null the listener in deinit." This is the trap, and it took me a while to see why:

deinit { vm.observe { _ in } }      // never runs

deinit runs when the last strong reference goes away. The cycle is a strong reference. The cleanup hook that would break the cycle is gated on the cycle being broken.

"Make the Kotlin field a weak reference." Kotlin/Native has WeakReference, and it does not apply — the field holds a function, not an object. There is no supported way to weakly hold a lambda that closed over a Swift object; the capture happened on the Swift side.

"Call a close() method from onDisappear." This works and is what we shipped, but as the only mechanism it is fragile: it depends on every screen remembering, and SwiftUI's onDisappear is not a guaranteed-once teardown in every navigation configuration. Correct as defence, wrong as the primary fix.

"Use Flow instead of a callback." The right architectural answer and it does not remove the problem by itself — a Flow collected from Swift still needs something holding the collection, and the same capture can appear in the for await body. SKIE's AsyncSequence bridging makes the ownership clearer, but [weak self] discipline still applies.

The actual mechanism

Two memory managers with different capabilities, meeting at an object boundary.

Kotlin/Native uses a tracing collector within its own heap. Cycles among pure Kotlin objects are collected normally — that part behaves as you expect.

A Kotlin object exposed to Swift gains ARC semantics. The exported object is an Obj-C object (KotlinBase), and Swift's ARC retains and releases it. Its lifetime is now governed by a reference count, and reference counting cannot free a cycle.

The cycle's two edges live in different runtimes. Edge one — Swift's let vm — is an ARC strong reference recorded in the Swift object. Edge two — Kotlin's onState field — is a Kotlin heap reference holding a Swift closure. Neither runtime can traverse the other's edge.

A two-runtime retain cycle: Swift's SearchModel holds the exported Kotlin ViewModel through an ARC strong reference, and the Kotlin ViewModel holds a Swift closure that captured self. Xcode's memory graph can see the Swift edge and shows the Kotlin object as an opaque KotlinBase; Kotlin's tooling can see its own field and nothing of the Swift capture. Neither profiler holds both halves.

Which yields the rule:

The moment a Kotlin object crosses to Swift, its lifetime is reference-counted, and every cycle through it is permanent. The JVM habit of ignoring cycles stops being safe at exactly that boundary — and no single profiler can show you the loop.

The fix

Break the cycle where the capture happens, and make the API shape push you toward doing so.

[weak self] in every closure passed into Kotlin. This is the whole fix:

init() {
    vm.observe { [weak self] state in
        guard let self else { return }
        self.state = state
    }
}

The Kotlin side still retains the closure; the closure no longer retains SearchModel. deinit now runs, and the chain unwinds.

Give the API a lifetime handle, so release is possible without deinit. The weak capture is the fix; this is what makes the design honest about ownership:

// commonMain
class Subscription internal constructor(private val onCancel: () -> Unit) {
    fun cancel() = onCancel()
}

class SearchViewModel {
    private val listeners = mutableListOf<(SearchStateView) -> Unit>()

    fun observe(listener: (SearchStateView) -> Unit): Subscription {
        listeners += listener
        return Subscription { listeners -= listener }
    }
}
private var sub: Subscription?

init() {
    sub = vm.observe { [weak self] state in self?.state = state }
}
deinit { sub?.cancel() }          // now reachable, because the cycle is already broken

Two things worth being precise about:

The handle does not fix the leak; it makes teardown deterministic. With a strong capture, the deinit still never runs. The handle is valuable for releasing early, and for making it obvious in the type signature that an observation has a lifetime.

Returning something the caller must store is a design pressure. A function that returns a Subscription invites a question — where do I keep this? — and that question is exactly the one that leads to thinking about ownership. The original observe that returned Unit invited nothing.

If the shared code must hold a reference back to a platform object, make it weak on the Kotlin side and keep the object, not a closure over it:

// commonMain
class ScreenBinding(host: Any) {
    private val ref = kotlin.native.ref.WeakReference(host)   // iosMain-shaped; see note
    fun host(): Any? = ref.get()
}

With the caveat that this is a native-only construct; a genuinely shared abstraction wants an expect/actual weak reference, which is more machinery than most call sites need. For almost every real case, [weak self] at the Swift call site is sufficient and is where the capture actually is.

How we proved it

The leak is a non-release, so the assertion is about something being deallocated.

A deinit log plus a push/pop loop, which is crude and completely decisive:

deinit { print("SearchModel deinit") }
before:  push/pop × 10   →  "SearchModel deinit" printed 0 times
after:   push/pop × 10   →  "SearchModel deinit" printed 10 times

Zero to ten. No profiler needed, and it is the thing a profiler was failing to show clearly.

Then the memory curve, to confirm the deinit is actually releasing the graph behind it:

                 push 1     push 5     push 10
before          18.2 MB    41.7 MB    72.4 MB
after           18.1 MB    18.6 MB    18.4 MB

Flat across ten navigations is the result. The small residue is caches that are supposed to persist.

One honest note on the diagnosis. I found this by adding the deinit print, not by reading the memory graph — the graph showed KotlinBase and I spent an hour trying to expand it. For a cross-runtime cycle, an ownership assertion you can run in seconds beats a tool that can only see one side. I would reach for the print first next time.

A limit: this covers Swift-closure-into-Kotlin, the common shape. A Kotlin object holding a Swift object (not a closure) via a strong reference has the same problem and needs the weak reference on the Kotlin side — and if that Kotlin object is itself only reachable from Swift, you are back to needing one of the two edges to be weak. The rule is that one edge must be weak; which one is a design choice.

What this generalizes to

At a runtime boundary, the weaker memory model governs. Tracing collection inside Kotlin, reference counting at the export. Whenever two memory managers meet, assume the capabilities you get are the intersection, not the union — and cycle collection is usually what you lose.

A profiler sees one runtime. Cross-boundary problems need an assertion that spans both: a deinit log, a finalizer counter, a count of live instances. Tooling is built per runtime; your bug is not.

Teardown gated on the thing you are trying to tear down never runs. deinit that breaks a cycle, a close() called from a destructor, a cleanup hook on an object held by the leak — all the same shape. Cleanup must be reachable from outside the thing being cleaned up.

An API that returns nothing teaches nothing about lifetime. observe() returning Unit invites a fire-and-forget call. Returning a handle makes the caller hold something, and holding something prompts the question of who owns it. Signatures are where ownership conventions live.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS