Skip to main content
The iPhone app of a Duet project on the Kotlin Multiplatform flavor runs two memory managers in one process. The Kotlin core runs under the Kotlin/Native garbage collector, which is linked into the app executable. The Swift code that calls the core runs under Swift’s automatic reference counting (ARC). This page shows what the app links, what the collector costs on the main thread, who frees each object that crosses between the two, and the rules a Duet shell follows as a result. Every figure on this page comes from a build you can reproduce: Foyer, the app the Duet tutorials build, at tutorial9-complete, and Memory Lane, a production app on the App Store built on the same flavor. Measured October 1, 2026, with Kotlin 2.4.10, SKIE 0.10.14 and Xcode 26.6 on an Apple M4 Pro. The iPhone app links one static XCFramework, FoyerKit.xcframework in Foyer. Its iPhone slice, ios-arm64/FoyerKit.framework/FoyerKit, is an ar archive with two object files:
  • FoyerKit.framework.o, written by the Kotlin/Native compiler: the Kotlin core compiled to arm64 machine code, an Objective-C class for every exported Kotlin class, and the Kotlin/Native runtime (the memory allocator, the garbage collector and the safepoint machinery).
  • FoyerKit.o, written by Swift’s compiler from source that SKIE generated: the Swift API your app calls. It contains no Kotlin code; it calls the Objective-C classes the first object defines.
A static framework is copied into the app executable at link time, so the collector runs inside the app’s own process, on threads the runtime starts.
Block diagram of the Foyer app executable: the app's Swift code calls SKIE's generated Swift in FoyerKit.o, a 1.5 MB object with 5,009 Swift symbols and no Kotlin functions, which calls 229 Objective-C classes defined in FoyerKit.framework.o, a 16.2 MB object holding the compiled core (7,361 Kotlin functions, 559 Objective-C classes) and the runtime (allocator, concurrent mark and sweep collector, safepoints)Block diagram of the Foyer app executable: the app's Swift code calls SKIE's generated Swift in FoyerKit.o, a 1.5 MB object with 5,009 Swift symbols and no Kotlin functions, which calls 229 Objective-C classes defined in FoyerKit.framework.o, a 16.2 MB object holding the compiled core (7,361 Kotlin functions, 559 Objective-C classes) and the runtime (allocator, concurrent mark and sweep collector, safepoints)

The three layers of a Release iPhone build of Foyer. The app's Swift code and SKIE's Swift are managed by ARC; the Kotlin objects are managed by the Kotlin/Native collector.

The figures for both apps, from the Release iosArm64 framework and the Release app executable: The object files are larger than the executable because they carry symbol tables and code the app never calls; the Release link drops both. Both executables contain the runtime’s message GC stop the world: concurrent mark took too long, which is one way to confirm the collector shipped.

How does Kotlin/Native manage memory?

Kotlin objects live in one heap that every thread shares. The Kotlin/Native memory manager reclaims them with a tracing collector:
  • The collector is concurrent mark and sweep, with no generations. It runs on its own thread and starts a collection on memory-pressure heuristics or on a timer.
  • Marking runs concurrently with the app’s threads. Each collection stops the app’s Kotlin-running threads twice, briefly; the runtime logs each stop as a “mutators pause”.
  • When marking takes too long, the runtime finishes it with the threads stopped and logs GC stop the world: concurrent mark took too long. When the heap grows past its target while a collection is running, the threads running Kotlin code wait until that collection finishes.
ARC frees a Swift object when its last strong reference goes away, at that moment and on that thread; the collector does not trace Swift objects.

What the collector costs on the main thread

The runtime prints one block of lines per collection when the framework is built with its log option:
src-kmp/apple-umbrella/build.gradle.kts
Each collection reports its two pauses as Mutators pause time #1 and #2, measured from the moment the collector asks the threads to stop until they resume, plus the objects it marked and the heap before and after. The two pause times added together are how long one collection holds the main thread. Time to pause is the part of a pause spent waiting for every thread to reach a stopping point. Foyer, a scripted 100-second walk on the iPhone 17 Pro simulator (iOS 26.5): guest sign-in, 55 taps on the buttons on screen, 14 deep links and swipes. Foyer allocates little, so its 10 collections came from the timer, one every 10.0 s, with about 970 live objects. The pauses of a collection added up to a median of 49 µs and at most 241 µs. A SwiftUI list scrolling at 60 frames per second on an iPhone 15 Pro (iOS 26.6.1), while the main thread creates Kotlin objects from Swift, far more than Foyer does, to show where the collector starts to cost frames. Each row is three 30-second runs of a Release build, timed by the app’s own display link: No pause lasted a full frame (16.7 ms), and the Kotlin runs dropped as many frames as the scroll with no Kotlin objects did. In the longest pause, 10.3 of the 13.7 ms was the wait for a thread to reach it. The collector-disabled build did not finish a run at 1,200,000 objects per second: it held 2.37 GiB when iOS logged a low-memory event, and it exited before its 30 seconds ended. Recording with Instruments changes the result. With the Animation Hitches template recording, the same 300,000-objects-per-second runs dropped 15 frames in 5,301, and the longest frame lasted 869 ms; the runs with no Kotlin objects and with the collector disabled dropped none by the app’s own timing. During the 869 ms frame the main thread was inside +[KotlinBase allocWithZone:], in the allocator’s page lookup, where it yielded the CPU 43,147 times, and the collector thread was blocked; the collection’s two logged pauses added up to 40 µs. When a hitch trace shows a long frame inside a Kotlin allocation, run the same scenario without the recording and time the frames in the app before attributing the frame to the collector. To see collections in Instruments next to your own intervals, also set kotlin.native.binary.enableSafepointSignposts=true in gradle.properties.

What does SKIE add?

The Kotlin/Native compiler exports the core through an Objective-C header. SKIE adds Swift source on top of that header, and Swift’s compiler builds it into the framework’s second object. SKIE generates wrappers only: the Kotlin runs as Kotlin/Native compiled it, and SKIE’s Swift calls the Objective-C classes. Foyer’s Release build generates 127 Swift files, under src-kmp/apple-umbrella/build/skie/binaries/releaseFramework/RELEASE/iosArm64/swift/generated/. Its StateFlow wrapper shows the pattern. The Swift class holds the Kotlin flow and reads through to it:
Skie/Skie.SkieSwiftStateFlow.swift (generated by SKIE 0.10.14)
Holding a SkieSwiftStateFlow therefore holds the Kotlin StateFlow. Iterating it with for await creates a SkieSwiftFlowIterator, which starts a Kotlin collection and cancels it in its deinit. What crosses as what:

What happens when Swift holds a Kotlin object?

Swift holds a Kotlin object through its Objective-C class, so ARC counts the references. The Kotlin runtime treats every object with a nonzero count as a root: the collector does not free it while Swift holds it. After the last Swift reference is released, the object is freed on a later collection, not at the release. The Kotlin page on ARC integration states it: “An object is reclaimed only during the garbage collection.” Nothing in the Kotlin object runs when Swift lets go of it.
Two panels. Swift objects: SplashViewShell holds BridgedStore; LiveSplashEnvironment holds the onDelegate closure, which points back to SplashViewShell through a weak capture. Kotlin objects: the Kotlin Store holds its StateFlow. BridgedStore holds the Kotlin Store through an ARC reference that is a GC root. The Kotlin Store retains LiveSplashEnvironment through objc_retain; the finalizer releases it on the main thread.Two panels. Swift objects: SplashViewShell holds BridgedStore; LiveSplashEnvironment holds the onDelegate closure, which points back to SplashViewShell through a weak capture. Kotlin objects: the Kotlin Store holds its StateFlow. BridgedStore holds the Kotlin Store through an ARC reference that is a GC root. The Kotlin Store retains LiveSplashEnvironment through objc_retain; the finalizer releases it on the main thread.

The objects behind one Foyer screen. ARC frees the Swift objects at their last release; the collector frees the Kotlin objects. The arrows that cross the boundary are the ones whose timing changes.

Measured with Foyer’s splash store in a test program on macOS (the harness in the cycle section): after the last Swift reference to the store was released, the store was freed 0.30 s later while the program allocated Kotlin objects, and 10.08 s later while it allocated nothing, when the collector’s timer fired.

What happens when Kotlin holds a Swift object?

A Duet feature hands Swift objects to its Kotlin store: the live environment (LiveSplashEnvironment in Foyer), each port implemented in Swift, and the closures they carry. The Kotlin object that holds one keeps it alive with an Objective-C retain. The Swift object is released after the collector frees its Kotlin holder. The runtime’s finalizer does the release, and the Kotlin page on deinitializers states where: on the main thread for objects passed to Kotlin on the main thread, and on a collector thread otherwise. The binary option kotlin.native.binary.objcDisposeOnMain=false moves every release to the collector thread. Because a Swift deinit may call back into Kotlin, it runs after the collection ends. A chain in which Kotlin and Swift objects alternate is therefore freed over several collections: the Kotlin page’s example, Kotlin → Swift → Kotlin → Swift, takes two. In the same test program, the environment’s deinit ran on the main thread 0.30 s after its store’s last Swift release while the program allocated, and 10.08 s after it while idle. Code in the deinit of a Swift object you hand to Kotlin runs that long after the screen that created the object closed.

Why does a cycle across the boundary leak, and how do you avoid it?

ARC frees a Swift cycle only when you break it with a weak or unowned reference. The collector frees a cycle of Kotlin objects on its own. A cycle that contains objects of both kinds is never freed: ARC does not see the Kotlin references, and the collector treats the Swift-held Kotlin object as a root. The Kotlin page on retain cycles: “if at least one Objective-C object is present, the retain cycle of a whole graph of objects cannot be reclaimed”. The cycle a Duet shell can create: an object owns a Kotlin store, the store holds its environment, and the environment’s delegate closure captures the owner strongly.
The fix is a weak capture in every closure that crosses into Kotlin:
The test program builds both owners, releases them, and allocates 4 million Kotlin objects: What the memory graph shows. leaks, the command-line reader for the memory graphs Xcode exports, reports 0 leaks for 0 total leaked bytes for the strong-capture owner, and lists the owner and its environment as live objects. The scanner cannot see the cycle: it scans the Kotlin heap as a root, so every Swift object the cycle holds is reachable from it. Find the cycle from the object that should be gone instead:
  1. Close the screen, wait 10 seconds for a collection, and export the memory graph (File ▸ Export Memory Graph in Xcode’s memory graph debugger).
  2. Find an instance of the owner type that should have been freed.
  3. Trace what holds it: leaks --traceTree=<address> <file>.memgraph. A path that runs through Region Memory Tag 246 goes through the Kotlin heap. 246 is the tag the Kotlin/Native allocator gives its memory unless the mmapTag binary option changes it.
Duet’s emitted code captures weakly in the closures it hands to Kotlin. BridgedStore’s collector task is Task { [weak self] in … }, and a parent shell routes a child’s delegate events with mounter.mountWelcome { [weak self] event in self?.store.send(…) }. Keep both forms when you edit them.

Why does a Duet shell stop its store explicitly?

Releasing a Kotlin store does not stop it. A store whose effects are still running stays reachable from them: a pending timer, for example, is held by the dispatcher that will resume it. The collector does not free such a store, and its effects keep running. A Duet shell therefore stops the Kotlin store as a step of its own. Foyer’s splash builder gives the bridge a teardown closure:
src-ios/Libraries/FoyerKit/Sources/SplashShell/SplashBuilder.swift
BridgedStore.cancel() cancels the Swift collector task and calls that closure. The shell adopts the bridged store into its StoreHost, which calls cancel() when the shell deactivates, in reverse order of adoption. Tutorial 2 writes this shell.

Why does BridgedStore re-read the state after send?

A Duet shell must observe a reduce before send returns: its view state and the child mounts follow from the new state in the same call. The Kotlin store reduces on the calling thread, because its scope runs on Dispatchers.Main.immediate. The bridged StateFlow delivers to Swift through for await, and every delivery crosses a continuation hop, so it arrives in a later main-actor turn. BridgedStore.send therefore forwards the action and then reads stateFlow.value in the same call. On each delivery its collector task re-reads value and discards the delivered element, because the bridged sequence buffers and can deliver a state older than the one send already mirrored. Tutorial 2 writes the mirror.

What happens to a Kotlin exception?

A Kotlin exception that reaches Swift through a function without @Throws terminates the process. With @Throws(SomeException::class) on the Kotlin function, Swift sees a throws function and the exception arrives as a Swift Error. Foyer’s boundary object annotates every exported function that can throw; Tutorial 2 shows it. The Kotlin page on Swift/Objective-C interop has the full rules.

How is Android different?

On Android the same Kotlin modules compile to JVM bytecode and run on ART, under ART’s garbage collector. The Compose shell, the store and the workers are objects in one heap, and the Android app calls the store’s send directly, with no generated layer and no retain/release at a boundary. The rules on this page apply to the iPhone app only. Inside a Duet Android app walks that tree.

How do you check your own build?

Run these against the Release framework of your app’s iPhone slice. The paths are Foyer’s; replace FoyerKit with your project’s framework name.
Foyer prints:
A Debug framework lists more objects: one per generated Swift file and one per cached Kotlin library. Use the Release framework for these counts. To check for the collector in the app you ship, search the executable inside the archive:

Common questions

Yes, twice per collection, and briefly in the measurements on this page. On an iPhone 15 Pro scrolling a list while the main thread created 300,000 Kotlin objects per second, the pauses of a collection added up to a median of 78 µs and at most 13.7 ms, and 1 frame in 5,399 was dropped, as many as in the same scroll with no Kotlin objects. In a scripted Foyer walk on the simulator the longest was 241 µs. What the collector costs on the main thread has the method, the flag to measure your own app, and how recording with Instruments changed the result.
Yes. @State holds a strong Swift reference, so the Kotlin object stays alive while the view does, and the collector frees it on a later collection after the view goes away. For a Kotlin value such as a state snapshot that is all you need. For a Kotlin store, use a shell and a BridgedStore: a store in @State is never stopped, and a store with running effects is never freed.
SKIE adds code size, measured above as 1.5 MB in Foyer’s framework and 2.6 MB in Memory Lane’s before the link, and one Swift wrapper object per value it wraps: an onEnum(of:) result, a SkieSwiftStateFlow, an iterator per for await. Each delivery of a bridged flow crosses a continuation hop. This page has no CPU measurement of SKIE’s wrappers.
The Kotlin/Native compiler runs inside Gradle, which runs on the JVM. The app scheme’s build pre-action runs scripts/assemble_kit.sh, which runs Gradle to compile the Kotlin core into the XCFramework before Xcode links it. The JDK is a build-machine requirement; the app contains no JVM.
Yes. On the Swift flavor, each feature’s reducer is Swift and the iPhone app links no Kotlin/Native framework, so none of this page applies to it. The Duet overview compares the two flavors. Duet Twins, in development, pairs a Swift core in the iPhone app with a Kotlin core in the Android app.

Sources and further reading

Tutorial 2: One Behavior, Two Apps

Build the framework this page measures, the BridgedStore mirror, and the shell that stops the Kotlin store.

Duet vs Compose Multiplatform

Both stacks run the same Kotlin/Native runtime on iOS; they differ in who draws the screen.

Inside a Duet Android app

The same core on Android, where it shares one heap with the Compose shell.

Duet glossary

Shell, store, host, mount and the other terms on this page.
Last modified on October 2, 2026