Engineering

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.

MobileByteSensei 11 min read
kmpcoroutinesios

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.
  • viewModelScope is cancelled by Android's ViewModel.onCleared(), which the framework calls. SwiftUI has no equivalent: a @StateObject wrapper 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 onDisappear alone 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

Forty push-pops of one screen leaving forty live view models and forty collectors on iOS against one on Android, with memory growing to thirty-eight megabytes and refresh fanning out to forty network calls

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.

Who owns the view model on each of four platforms and what termination signal exists, with one guaranteed callback, one reference-counting tendency, and two cases left entirely to the author

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.

Instrumenting clear across both platforms, enumerating which navigation actions fire each candidate signal, and measuring that a stray strong reference leaves six of forty instances alive

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.


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".

Follow by RSS