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.
What does the iPhone app link?
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.
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.
iosArm64 framework and the Release app executable:
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.
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: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:
+[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, undersrc-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:
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.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.
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 aweak 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.
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:
- Close the screen, wait 10 seconds for a collection, and export the memory graph (File ▸ Export Memory Graph in Xcode’s memory graph debugger).
- Find an instance of the owner type that should have been freed.
- Trace what holds it:
leaks --traceTree=<address> <file>.memgraph. A path that runs throughRegion Memory Tag 246goes through the Kotlin heap. 246 is the tag the Kotlin/Native allocator gives its memory unless themmapTagbinary option changes it.
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: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’ssend 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; replaceFoyerKit with your project’s framework name.
Common questions
Does the collector pause the main thread?
Does the collector pause the main thread?
Can a SwiftUI view hold a Kotlin object in @State?
Can a SwiftUI view hold a Kotlin object in @State?
@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.Does SKIE add runtime cost?
Does SKIE add runtime cost?
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.Why does the iPhone build need a JDK?
Why does the iPhone build need a JDK?
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.Can an iPhone app use Duet without the Kotlin runtime?
Can an iPhone app use Duet without the Kotlin runtime?
Sources and further reading
- Kotlin/Native memory management: the collector, its triggers, the
gcand-Xruntime-logsoptions and the safepoint signposts - Integration with Swift/Objective-C ARC: reclamation timing, main-thread deinitialization,
objcDisposeOnMainand retain cycles - Interoperability with Swift/Objective-C: how Kotlin declarations map to Objective-C, and
@Throws - SKIE: Flows, Sealed classes and Suspend functions: the Swift layer SKIE generates
- Gathering information about memory use: Xcode’s memory graph debugger and
.memgraphfiles - Understanding hitches in your app: how Apple defines a hitch and measures it with Instruments
- Kotlin/Native GC scheduler,
MutatorAssists.hpp: why threads running Kotlin code wait for a running collection when the heap passes its target - The duet-tutorials repository:
tutorial9-complete, the Foyer tree measured on this page
Read next
Tutorial 2: One Behavior, Two Apps
BridgedStore mirror, and the shell that stops the Kotlin store.