The coroutine that kept collecting after the screen was gone
Android cancels viewModelScope for you and SwiftUI does not. One scope, two lifetimes, and only one of them has a callback that fires when the screen ends.
TL;DR
- The same shared ViewModel leaked on iOS and not on Android. After 40 push-pops of one screen, the
iOS process held 40 live collectors on the same
StateFlow, 38 MB of retained objects, and was making 40 duplicate network calls per refresh. viewModelScopeis cancelled by Android'sViewModel.onCleared(), which the framework calls. SwiftUI has no equivalent: a@StateObjectwrapper is deinitialised eventually, by ARC, at a time nobody specifies.- The fix is to make cancellation the consumer's responsibility with a method the shared code exposes, and to make a leak detectable in a test rather than in a memory graph.
- What reading on buys: the per-platform ownership table, why
onDisappearalone is wrong, and the assertion that fails when a collector survives.
The case this post is about
A shared ViewModel with a scope that cancels itself — on one platform:
// core/common/src/commonMain/kotlin/BaseViewModel.kt
abstract class BaseViewModel(dispatchers: AppDispatchers) {
protected val scope = CoroutineScope(SupervisorJob() + dispatchers.main)
fun clear() = scope.cancel()
}
// feature/feed/src/commonMain/kotlin/FeedViewModel.kt
class FeedViewModel(private val repo: FeedRepo, d: AppDispatchers) : BaseViewModel(d) {
val state = MutableStateFlow(FeedState.Loading)
init {
scope.launch {
// A long-lived collection: the repo emits on every cache write, forever.
repo.feed().collect { state.value = FeedState.Ready(it) }
}
}
}
The Android host, where clear() is wired to a framework callback:
// androidMain — an androidx.lifecycle.ViewModel wrapping the shared one
class AndroidFeedViewModel(private val shared: FeedViewModel) : ViewModel() {
override fun onCleared() { shared.clear() } // called by the framework
}
And the iOS host, where it is wired to nothing:
// iosApp/FeedScreen.swift
struct FeedScreen: View {
@StateObject private var model = FeedObservable(vm: FeedViewModel(repo: …, d: …))
var body: some View { FeedList(state: model.state) }
}
The symptom
Memory that grew with navigation and never came back, and a refresh that fanned out:
# push → pop of the feed screen, 40 times, iOS 17 device
iteration live FeedViewModel live collectors on repo.feed() retained
1 1 1 0.9 MB
10 10 10 9.4 MB
40 40 40 38.1 MB
# and the user-visible part
network calls per pull-to-refresh after 40 pushes 40
The same 40 iterations on Android:
iteration live FeedViewModel live collectors retained
40 1 1 0.9 MB
What made it hard to see is that nothing failed. The screen worked, the data was correct, and the duplicated network calls were idempotent GETs that returned the same bytes — so the only symptom was a number in a memory graph nobody looks at until a crash report arrives.
# how it was actually found
user report "app gets slow after a while" (3 reports, 6 weeks)
crash reports 0 ← never OOM'd on a modern device
the leak in Instruments found in 20 minutes, once looked for

Why the obvious fix failed
The first fix was onDisappear:
.onDisappear { model.vm.clear() }
Which fires when the view leaves the screen — including when it is pushed over by a detail
screen, and when a TabView switches tabs. So the feed's collector was cancelled while the feed
was still on the navigation stack, and coming back showed FeedState.Loading forever because
nothing restarted it.
navigation action onDisappear fires? should the scope die?
pop the feed off the stack yes yes
push a detail OVER the feed yes NO ← broke here
switch to another tab yes NO ← and here
app backgrounded no no
Two of four wrong, and both wrong in the direction that breaks a working screen rather than leaking.
The second fix was to make the shared scope tied to the observable's lifetime via deinit:
class FeedObservable: ObservableObject {
deinit { vm.clear() }
}
This is closer and still not a specification: deinit runs when ARC's last reference goes, which is
usually soon after the view is destroyed and is not guaranteed to be, and never runs at all if
anything retains the observable — which, in our case, a closure captured in a Combine sink did.
That is how we got from 40 leaks to 6.
The third attempt was to make the shared code cancel itself after a period of no collectors —
SharingStarted.WhileSubscribed on the state. Which fixes the collector leak and leaves the
FeedViewModel instances alive, because the leak is the object graph, not just the subscription.
The actual mechanism
A CoroutineScope lives until something cancels it. The question is who that something is, and the
answer is a platform fact rather than a Kotlin one.
On Android, the framework owns a lifecycle and calls you. androidx.lifecycle.ViewModel is
retained by a ViewModelStore tied to an Activity/NavBackStackEntry, and when that store is
cleared the framework calls onCleared(). viewModelScope is literally a scope cancelled in that
callback. The guarantee is: a specific method is invoked at a defined moment.
On iOS, there is no such callback. @StateObject ties an object's reference to a view's
identity, and SwiftUI releases that reference when the view's identity goes away. What happens then
is ARC: if the retain count hits zero, deinit runs. There is no documented moment, no method
SwiftUI calls on your object to say "you are done", and any other strong reference postpones it
indefinitely.
platform owner of the ViewModel termination signal guaranteed?
Android ViewModelStore onCleared() yes, at a defined moment
iOS SwiftUI @StateObject deinit, via ARC no — timing unspecified, and
suppressed by any strong ref
Desktop your own code whatever you wrote only if you wrote it
Web your own code whatever you wrote only if you wrote it
So the shared code's clear() is not optional on three of four platforms — and the one platform
where it is automatic is the one most developers write first, which is why the method existed and
was wired only once.
And onDisappear is not a lifetime signal. It reports visibility, which is a different
question from existence: a view pushed over is invisible and must keep its state. There is no
SwiftUI callback for "this view's identity is being discarded", which is the event you actually
want — the closest proxy is @StateObject deinit, and that is ARC.

The fix
Make the lifetime explicit on the consumer side using the one SwiftUI mechanism whose timing is tied to view identity, and make the leak assertable in a test so the next host that forgets is caught by CI rather than by Instruments.
// core/common/src/commonMain/kotlin/BaseViewModel.kt
abstract class BaseViewModel(dispatchers: AppDispatchers) {
protected val scope = CoroutineScope(SupervisorJob() + dispatchers.main)
/**
* MUST be called by the host. Android's ViewModelStore calls onCleared() at a defined
* moment; SwiftUI, Compose Desktop and the web host have no equivalent callback, so on
* three of four platforms this is the ONLY thing that cancels the scope.
*
* Idempotent: a host that calls it twice (deinit after an explicit clear) must not crash,
* and cancelling a cancelled scope is already a no-op — stated here because the iOS host
* does exactly that.
*/
fun clear() {
// The guard is what makes the doc above true. Without it a double clear() — which the
// iOS host produces whenever deinit follows an explicit call — runs the subclass's
// onClear() twice, and nothing about `scope.cancel()` being a no-op protects that.
if (!scope.isActive) return
scope.cancel()
onClear()
}
/** Subclass teardown. Runs after cancellation, so no handler is mid-flight. */
protected open fun onClear() {}
/**
* Public deliberately, not `internal`: the leak test lives in the feature module that owns
* the subclass, and `internal` here is visible only within core:common. A property that
* exists for a test is worth the widened visibility; a test that cannot see it is not.
*/
val isCleared: Boolean get() = !scope.isActive
}
// iosApp/Shared/ViewModelHolder.swift
//
// @StateObject's lifetime IS tied to view identity, which is the signal we want; deinit is
// how that lifetime is observed. The failure mode is a stray strong reference postponing
// deinit — measured: a Combine sink capturing self took us from 40 leaks to 6 rather than 0 —
// so the holder keeps NOTHING but the view model and is never captured anywhere.
final class ViewModelHolder<VM: BaseViewModel>: ObservableObject {
let vm: VM
init(_ vm: VM) { self.vm = vm }
deinit { vm.clear() }
}
struct FeedScreen: View {
@StateObject private var holder = ViewModelHolder(FeedViewModel(repo: …, d: …))
// NOT onDisappear: it fires when a detail screen is pushed OVER this one and when the tab
// changes, both of which must keep the scope alive. Two of four navigation actions were
// wrong in the direction that breaks a working screen.
var body: some View { FeedList(vm: holder.vm) }
}
And the test, which is the part that generalises — it asserts the contract rather than any one host's wiring:
// commonTest/kotlin/BaseViewModelLifetimeTest.kt
class BaseViewModelLifetimeTest {
@Test
fun clearCancelsEveryLaunchedCollector() = runTest {
val repo = FakeFeedRepo() // counts live collectors
val vm = FeedViewModel(repo, TestDispatchers(StandardTestDispatcher(testScheduler)))
runCurrent()
assertEquals(1, repo.liveCollectors, "the ViewModel should be collecting")
vm.clear()
runCurrent()
assertEquals(0, repo.liveCollectors,
"a collector survived clear() — on iOS/desktop/web nothing else will cancel it")
assertTrue(vm.isCleared)
}
@Test
fun clearIsIdempotent() = runTest {
// The iOS host calls clear() from deinit, which can follow an explicit clear.
val vm = FeedViewModel(FakeFeedRepo(), TestDispatchers(StandardTestDispatcher(testScheduler)))
vm.clear(); vm.clear()
assertTrue(vm.isCleared)
}
@Test
fun fortyConstructAndClearCyclesLeaveNoCollectors() = runTest {
// The shape of the production symptom, in a test that runs in 40 ms.
val repo = FakeFeedRepo()
repeat(40) {
FeedViewModel(repo, TestDispatchers(StandardTestDispatcher(testScheduler)))
.also { runCurrent() }.clear()
}
runCurrent()
assertEquals(0, repo.liveCollectors)
assertEquals(40, repo.totalCollectionsStarted) // all started, all finished
}
}
And the output it produced:
$ ./gradlew :feature:feed:allTests --tests '*BaseViewModelLifetime*'
clearCancelsEveryLaunchedCollector PASSED × 4 targets
clearIsIdempotent PASSED × 4 targets
fortyConstructAndClearCyclesLeaveNoCollectors PASSED × 4 targets
$ # the same 40 push-pops on device, after
iteration live FeedViewModel live collectors retained
1 1 1 0.9 MB
40 1 1 0.9 MB
network calls per pull-to-refresh 1
And across the iOS app, the holder replaced eleven hand-written wrappers:
screens with @StateObject on a shared ViewModel 11
screens that called clear() before this change 2
screens that call it now 11
Nine of eleven screens were leaking. Two had been fixed by hand, which is exactly how a problem stays invisible — the two that mattered most got fixed and the pattern never did.
How we proved it
That Android's cancellation comes from the framework, not from the shared code. The measurement that located the asymmetry, since the shared code is byte-identical:
$ # instrument clear() with a log line, run 40 push-pops
android clear() called 40 times ← from onCleared()
ios clear() called 0 times
Same BaseViewModel, same clear(), called forty times on one platform and never on the other.
That onDisappear fires on the wrong events. Enumerated by instrumenting it, because the
mistake is easy to reason about wrongly:
action onDisappear deinit correct signal
pop off the stack fired fired either
push a detail over it fired — deinit
switch tab fired — deinit
background the app — — neither
That a stray strong reference defeats deinit. The reason the holder keeps nothing — found by
measuring the "fixed" version rather than assuming it:
implementation leaked instances after 40 cycles
no cleanup 40
deinit, observable captured in a Combine sink 6
deinit, holder captures nothing 0
Six is the number that proves the mechanism: ARC had mostly collected, on no schedule we control.
That the test fails on the original code. RED first, since a leak test that has never seen a leak proves nothing:
$ git stash && ./gradlew :feature:feed:allTests --tests '*clearCancelsEvery*'
clearCancelsEveryLaunchedCollector FAILED
a collector survived clear() — on iOS/desktop/web nothing else will cancel it
expected: 0 actual: 1
And the reason it failed was not the one I expected, which is the most useful thing the test did.
clear() was being called and was cancelling the base scope correctly. FeedViewModel had at
some point acquired its own CoroutineScope(Dispatchers.Main) and was launching into that, so the
collector lived in a scope clear() had never heard of.
That is a second, independent instance of the same class of bug, and it was invisible on Android
too — onCleared() fired, clear() ran, and the subclass's private scope survived on every
platform. A leak test written for the iOS asymmetry found a defect that was never asymmetric.
What I cannot tell you is what happens under SwiftUI's view-identity churn during a navigation
animation. We observe one deinit per pop in practice, and SwiftUI is free to create and destroy
view values repeatedly — @StateObject is documented to construct once per identity, and we are
relying on that documentation rather than on a measurement of every animation path.
And an honest gap. Nothing enforces that an iOS screen uses ViewModelHolder. A new screen can
write @StateObject private var vm = FeedViewModel(...) directly and leak exactly as before; the
Kotlin test proves clear() works, not that Swift calls it. A lint rule on the Swift side is the
obvious next step and is not written.

What this generalizes to
When shared code holds a resource, the release is the part that does not port — one platform's framework calls a method at a defined moment, another leaves it to a reference-counting collector, and two more leave it to you. Code written against the first reads as complete, because on that platform it is. The question worth asking of any shared object that owns a scope, a subscription, or a handle is not "does it clean up" but "who calls the cleanup, and is that a guarantee or a tendency" — and then to assert the contract in a shared test, since the host that forgets will not tell you.
Related
Dispatchers.Mainthrows on a target you forgot to configure — the same scope, where the construction rather than the teardown is the platform fact.- A shared ViewModel has four lifecycles — the ownership table above, worked through per platform.
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".