A Flow does not cross to Swift. A callback does.
Exporting a Flow hands Swift something it has no way to collect.
TL;DR
- A
StateFlow<SearchState>exported to Swift arrives asid<SharedKotlinx_coroutines_coreStateFlow>with no collect method Swift could call —collectissuspendand takes aFlowCollector, and neither crosses usefully. - The workaround that shipped — a
watch(onEach:)callback — leaked: after fourteen navigations, fourteen Kotlin controllers were still live anddeinithad never run on any of them, because nothing returned a way to stop watching. - The shape that works is a subscribe call returning a cancellation handle, plus
[weak self]at the capture site. Both halves are required; either alone still leaks. - What reading on buys: why
Flowcannot be exported as a collectable type, the handle's implementation, and the measurement that distinguishes a missing cancel from a retain cycle.
The case this post is about
A search controller in shared code, exposing state the way every Kotlin consumer wants it:
// commonMain/kotlin/search/SearchController.kt
class SearchController(private val repo: SearchRepo) {
private val _state = MutableStateFlow<SearchState>(SearchState.Idle)
val state: StateFlow<SearchState> = _state.asStateFlow()
suspend fun search(q: String) {
_state.value = SearchState.Loading
_state.value = SearchState.Ready(repo.search(q))
}
}
Android collects it in one line:
val state by controller.state.collectAsStateWithLifecycle()
And Swift gets this in the generated header:
@interface SharedSearchController : SharedBase
@property (readonly) id<SharedKotlinx_coroutines_coreStateFlow> state;
@end
@protocol SharedKotlinx_coroutines_coreFlow
- (void)collect:(id<SharedKotlinx_coroutines_coreFlowCollector>)collector
completionHandler:(void (^)(SharedKotlinUnit * _Nullable, NSError * _Nullable))completionHandler;
@end
A collect that wants a FlowCollector — which is a Kotlin interface with a single suspend
method, and implementing a suspend-returning Obj-C protocol method from Swift is not a thing you
do.
The symptom
The workaround was the obvious one, and it shipped:
// commonMain — the first attempt
class SearchController(private val repo: SearchRepo) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
fun watch(onEach: (SearchState) -> Unit) {
scope.launch { state.collect { onEach(it) } }
}
}
// iosApp — what every screen did
final class SearchViewModel: ObservableObject {
@Published var state: SearchState = .idle
private let controller = SearchController(repo: repo)
init() {
controller.watch { [self] s in self.state = s }
}
}
That works, visibly, immediately. And:
# Xcode ▸ Debug Memory Graph, after navigating into and out of search 14 times
SearchViewModel 14 instances ← one visible screen
SharedSearchController 14 instances
Fourteen controllers, each with a live coroutine collecting a flow and writing into a view model
whose screen was gone. deinit never ran on any of them.
The second symptom took longer to connect: the search API was being called from screens the user had left. Our own server logs showed it:
$ grep 'GET /search' access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head -3
14 /search?q=kotlin ← one typed query, fourteen requests
Each retained controller was still subscribed, so a shared repository refresh fanned out to all of them.

Why the obvious fix failed
The first fix was [weak self], which is the correct Swift-side half and is not sufficient:
controller.watch { [weak self] s in self?.state = s }
SearchViewModel 1 instance ← fixed
SharedSearchController 14 instances ← not fixed
The view model is released and the controller is not, because the controller's own scope holds the
collecting coroutine and nothing ever cancels it. [weak self] breaks the cycle back to Swift;
it does nothing about the Kotlin side holding a live subscription to its own flow.
The second fix was to cancel the scope in deinit:
deinit { controller.close() }
Which works and relies on deinit running — and deinit is exactly what was not running before
the weak capture. So this fix is correct only in combination with the first, and on its own it
is a no-op. That ordering caught me: I applied them one at a time, saw each fail, and nearly
concluded neither was the answer.
The third attempt was SKIE, which generates Swift-friendly wrappers over Flow and does solve
this well. We did not adopt it for this module, for a reason worth stating: it is a compiler plugin
in the build of a library consumed by a team that did not want another plugin in their toolchain.
That is a constraint, not a technical verdict — if you can use it, the rest of this post is a
description of what it generates.
The actual mechanism
Flow is a Kotlin abstraction whose entire contract is expressed in suspend functions, and
suspend functions cross the Obj-C boundary as completion handlers.
collect is suspend and takes a FlowCollector. The exporter turns the suspend into a
completion handler, which is fine — and the parameter is a Kotlin interface whose single method
is also suspend, so Swift would have to implement a protocol method that takes a completion
handler and calls it when it has finished emitting. Possible to write and not how anyone wants to
consume a stream.
There is no cancellation token in the exported shape. A Kotlin collector is cancelled by
cancelling the coroutine that is collecting. That coroutine lives in a Job, and nothing
referencing the Job crosses the boundary unless you put it there. So a watch(onEach:) function
hands Swift a subscription with no off switch — the API is missing a parameter, and the
compiler cannot say so because "returns Unit" is a legal signature.
And the scope's lifetime is the controller's, which on iOS nothing manages. Dispatchers.Main.immediate
makes the callbacks arrive on the right thread and has no bearing on when the scope dies.
So the leak has two independent causes and both must be fixed:
Swift → Kotlin the view model holds the controller → ARC strong reference
Kotlin → Swift the controller's coroutine holds the closure → a Kotlin heap reference
which captured `self` → the cycle
Kotlin → Kotlin the scope holds the coroutine → never cancelled
[weak self] cuts the second edge. Nothing cuts the third except an explicit cancel, and nothing
can call that cancel unless the subscribe returned something to call it on.

The fix
A subscribe function returning a cancellation handle, and a Swift wrapper that owns it.
// commonMain/kotlin/interop/Cancellable.kt
//
// A one-method CLASS rather than a `fun interface`. Both export and both can be held from
// Swift — a `fun interface` becomes an Obj-C protocol, and `any SharedCancellable` is a
// perfectly storable property. The class earns its place for a smaller reason: it gives the
// handle a concrete exported type, so the Swift property is `Cancellable?` rather than an
// existential, and the idempotence below lives in one place instead of in every implementor.
class Cancellable internal constructor(private val onCancel: () -> Unit) {
private val cancelled = atomic(false)
/** Idempotent: deinit and an explicit stop may both call this, in either order. */
fun cancel() {
if (cancelled.compareAndSet(expect = false, update = true)) onCancel()
}
}
// commonMain/kotlin/search/SearchController.kt
class SearchController(private val repo: SearchRepo) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val _state = MutableStateFlow<SearchState>(SearchState.Idle)
/** For Kotlin callers — the real thing, unchanged. */
val state: StateFlow<SearchState> = _state.asStateFlow()
/**
* For Swift. Returns a handle, which is the parameter the first version was missing:
* without it the caller has a subscription and no way to end it, and no amount of
* discipline at the call site can supply one.
*
* The initial value arrives before this function returns, so a SwiftUI view has state on
* first render rather than a frame of placeholder — but NOT because of an explicit call.
* StateFlow replays its current value to every new collector, and Main.immediate does not
* re-dispatch when it is already on the main thread, so `launch` runs the body up to the
* first suspension inline. An extra `onEach(_state.value)` here was the first version and
* it delivered the initial state TWICE: once by hand, once by the replay.
*/
fun watch(onEach: (SearchState) -> Unit): Cancellable {
val job = scope.launch { state.collect { onEach(it) } }
return Cancellable { job.cancel() }
}
fun close() = scope.cancel()
}
The Swift side, where the handle is owned by the thing whose lifetime should bound it:
// iosApp/Sources/SearchViewModel.swift
@MainActor
final class SearchViewModel: ObservableObject {
@Published var state: SearchState = .idle
private let controller: SearchController
private var subscription: Cancellable?
init(repo: SearchRepo) {
self.controller = SearchController(repo: repo)
// [weak self] cuts the Kotlin→Swift edge. The stored handle cuts the Kotlin→Kotlin
// one. BOTH are required: with only the weak capture the coroutine runs forever;
// with only the handle, deinit never fires so cancel() is never called.
subscription = controller.watch { [weak self] s in
guard let self else { return }
self.state = s
}
}
deinit {
subscription?.cancel()
controller.close()
}
}
And the output it produced — the same fourteen-navigation run, read from the memory graph:
$ # navigate into and out of search 14 times, then Debug Memory Graph
SearchViewModel 0 instances
SharedSearchController 0 instances
SharedCancellable 0 instances
$ grep -c 'GET /search?q=kotlin' access.log
1
One request for one typed query, and nothing retained.
How we proved it
That the two fixes are independent and both necessary — the matrix, because applying them one at a time is what made each look useless:
SearchViewModel SearchController /search calls
as shipped 14 14 14
+ [weak self] only 1 14 14
+ handle, cancelled in deinit only 14 14 14
+ both 0 0 1
Row three is the instructive one: the handle alone changes nothing, because deinit is what calls
cancel() and the retain cycle is what prevents deinit. A fix that depends on a callback the
bug suppresses will always measure as a no-op.
That the leak was a live subscription and not merely a retained object. A retained object with a dead coroutine is a memory problem; a retained object with a live one is also a correctness and a cost problem, and the server log separates them:
# 14 retained controllers, repository refresh triggered once
$ grep -c 'GET /search' access.log
14
Fourteen outbound requests for one user action is the measurement that made this a priority rather than a cleanup task.
What the two declarations actually export, read from the generated header rather than assumed — because I had believed one of them did not cross, and it does:
# fun interface Cancellable { fun cancel() }
$ grep -A2 'protocol SharedCancellable' shared.h
@protocol SharedCancellable
- (void)cancel; ← an existential: Swift holds `any SharedCancellable`
# class Cancellable(private val onCancel: () -> Unit)
$ grep -A2 'interface SharedCancellable' shared.h
@interface SharedCancellable : SharedBase
- (void)cancel; ← a concrete type: Swift holds `Cancellable`
Both are storable, so this is an ergonomics choice and not the load-bearing one. The
load-bearing part is that watch returns something; what that something is declared as
is a matter of taste.
That cancel() is safe to call twice. The fixture is the real sequence — an explicit stop
followed by deinit — which without the atomic would cancel an already-cancelled job and, in the
version before this one, re-enter onCancel on a scope that had been cancelled:
$ ./gradlew :shared:jvmTest --tests '*CancelIsIdempotent*'
PASSED cancel() × 3 → onCancel invoked 1 time
What I cannot tell you is whether the synchronous initial emission is right for every consumer.
It removes a frame of placeholder in SwiftUI and it means watch invokes its callback before
returning — so a caller that assigns the handle and mutates state in the callback sees the
mutation first. That ordering surprised one reviewer. It is documented and it is a trade, not a
clean win.
And an honest scope limit. Fourteen screens in one app, one flow type. The mechanism is the export shape rather than anything about search, so I expect it to transfer; the leak count is a function of how many screens subscribe, which is the least interesting number in the post.

What this generalizes to
An abstraction expressed entirely in a language's own features does not cross a boundary that
lacks them, and the honest move is to export a different shape rather than a degraded version of
the same one. The question to ask of any streaming API at an FFI is not how to export the stream
but what the consumer must be able to do — subscribe, receive, and stop — and then to check
that the exported signature contains all three. A subscribe that returns Unit is missing the
third, and nothing in the type system will mention it.
Related
- A suspend function reaches Swift as a completion handler — the cancellation token that does not cross, one level down from this.
- A two-runtime retain cycle no single profiler can see — the Kotlin→Swift edge in isolation, and why Xcode shows the Kotlin half as opaque.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".