iOS Keeps Observing a Dead Kotlin Flow — the CommonFlow Leak Hiding in Your KMP Bridge
So you've wrapped your shared Flow<T> for Swift consumption. Something like this on the Kotlin side:
class CommonFlow<T : Any>(private val wrapped: Flow<T>) {
fun watch(block: (T) -> Unit): CommonFlowCancellation {
val job = Job()
wrapped.onEach {
block(it)
}.launchIn(CoroutineScope(Dispatchers.Main + job))
return CommonFlowCancellation(job)
}
}
class CommonFlowCancellation(private val job: Job) {
fun cancel() {
job.cancel()
}
}
And on the Swift side, something like:
class ProfileViewModel: ObservableObject {
@Published var state: ProfileState = .loading
private var cancellation: CommonFlowCancellation?
func observe(_ repo: ProfileRepository) {
cancellation = repo.observeProfile().watch { [weak self] state in
self?.state = state
}
}
}
This works perfectly in the demo. It works in QA. Then three sprints later someone
files a bug: "the profile screen stops updating after you navigate away and back a few
times" — or worse, a slow memory climb that only shows up after twenty minutes of real
usage, nowhere near your five-minute manual test pass.
The actual bug
observe() gets called again every time the view reappears — in onAppear, in
viewDidLoad on a reused view controller, in a SwiftUI view's init if you're not
careful. Each call overwrites cancellation with a new CommonFlowCancellation,
dropping the reference to the previous one.
The previous Job is still running. Nobody ever called cancel() on it, because the
only object that held a reference to it just got replaced. The old collector keeps
emitting into a block closure that captured self — except now you have two live
collectors fighting to set self.state, and the view model itself can't deallocate
because the dangling Job's coroutine scope still holds a strong path back to it
through the closure.
This is not a Kotlin bug and not a SKIE bug. It is the exact same "forgot to dispose
the subscription" class of bug that RxJava/RxSwift developers have been shipping since
2015 — KMP's Flow bridge just gives it a new home.
Why [weak self] doesn't save you here
The [weak self] in the closure stops the view model from leaking. It does nothing
for the orphaned Job. That job has its own CoroutineScope, its own dispatcher
queue, and its own onEach collector — all of that keeps running, consuming CPU and
holding onto whatever it: T values flow through it, completely independent of
whether self is nil. A leaked Job with a dead weak self just means your observer
now silently no-ops forever instead of crashing — which is worse, because nothing
tells you it's broken.
The fix: cancel before you resubscribe, every time
func observe(_ repo: ProfileRepository) {
cancellation?.cancel()
cancellation = repo.observeProfile().watch { [weak self] state in
self?.state = state
}
}
One line. The entire bug is that nothing ever called cancel() on the way out. If your
view can call observe() more than once in its lifetime — which almost every
real-world view can, via re-navigation, tab switching, or SwiftUI re-initialization —
this line is not optional.
Making the mistake impossible instead of just fixed
A one-line fix is easy to re-break the next time someone adds a new observer call site.
Better to make the lifecycle explicit on the Kotlin side too, so cancellation isn't
something Swift callers have to remember:
class CommonFlow<T : Any>(private val wrapped: Flow<T>) {
private var activeJob: Job? = null
fun watch(block: (T) -> Unit): CommonFlowCancellation {
activeJob?.cancel() // kill any previous collector on THIS flow instance first
val job = Job()
activeJob = job
wrapped.onEach { block(it) }
.launchIn(CoroutineScope(Dispatchers.Main + job))
return CommonFlowCancellation(job)
}
}
This doesn't replace disciplined cancellation on the Swift side — a view model that
outlives its view still needs cancellation?.cancel() in deinit — but it means a
double-watch() call on the same wrapper (the actual bug above) can't silently stack
two live collectors anymore. Defense in depth matters more here than in pure-Kotlin
code, because Swift has no compiler warning for "you dropped a reference to something
that was holding a background job open."
The broader pattern
Anywhere a KMP shared module hands Swift something that needs explicit teardown —
Flow collectors, platform expect/actual resource handles (file handles, native
camera sessions), long-lived Koin-scoped singletons — the failure mode is the same:
Kotlin's structured concurrency assumes a CoroutineScope owner that cancels it, but
Swift's ARC has no equivalent "scope" concept, so the handoff point is exactly where
leaks are born. Treat every cross-boundary Flow/callback bridge as a resource that
needs an explicit, defensive cancel-on-resubscribe path, not just a cancel-on-deinit
path — re-navigation will exercise the first case long before deinit ever exercises the
second.
If you're setting up a KMP shared module from scratch and want the Ktor client, Room,
Koin DI, and navigation wiring done right the first time (with the trap callouts for
this kind of lifecycle mismatch built in), I put together a Kotlin Multiplatform &
Compose Multiplatform Developer Cookbook with 10
production-grade recipes and a ready-to-clone starter project.
Top comments (1)
tr.ee/dev-to