Engineering

A Flow is cold, and that is why your ViewModel fires three network calls

Each collector re-runs the producer. Two UI observers plus a test is three executions.

MobileByteSensei 9 min read
kmpcoroutinesarchitecture

TL;DR

A Flow is a recipe, not a stream. The block you passed to flow { } runs once per collector, from the beginning, every time. Nothing is shared by default.

So a repository flow collected by a list composable, a badge composable, and a test runs three times — three network calls, three cache writes, three analytics events. The code looks like one pipeline because there is one declaration.

shareIn and stateIn are how you say "one execution, many observers". Which one, and with what started policy, is a decision about what a late subscriber sees — not a performance knob you sprinkle on.

The case this post is about

An ordinary repository exposing a flow of search results:

// commonMain
class ReadingRepository(private val api: Api, private val cache: Cache) {

    fun items(): Flow<List<Item>> = flow {
        emit(cache.read())                    // fast local
        val fresh = api.fetchItems()          // network
        cache.write(fresh)                    // persist
        analytics.log("items_fetched")
        emit(fresh)
    }
}

Two composables need it. A list, and a badge showing the count:

// in the ViewModel
val items  = repo.items()
val badge  = repo.items().map { it.size }

Both read "the same" flow. There is one function, one declaration, one pipeline in the mental model.

The symptom

The API dashboard, after shipping:

GET /items    requests per screen open:  2.9 avg

Nearly three calls per screen open, from a screen that conceptually loads once. And the analytics, which is how it got noticed:

items_fetched   per session:  41
screens opened  per session:  14

Then the test suite made it four, and the test was the clearest demonstration because it is single-threaded and deterministic:

@Test
fun loadsItems() = runTest {
    val repo = ReadingRepository(fakeApi, fakeCache)
    repo.items().first()
    repo.items().first()
    assertEquals(1, fakeApi.fetchCount)   // expected 1, got 2
}

Two collections, two fetches. No concurrency, no lifecycle, nothing platform-specific — just the flow doing exactly what a cold flow does.

Why the obvious fix failed

"Cache the flow in a property." This is the trap that looks like the answer:

private val itemsFlow = repo.items()      // same cold Flow, stored

A Flow is an object describing work. Storing it stores the recipe. Every collector of itemsFlow still runs the block. The variable made it look shared and changed nothing, which is worse than the original because now it reads as fixed.

"Collect once in the ViewModel and expose a StateFlow." Correct in direction, and my first version lost the loading state — I collected into a MutableStateFlow initialised with an empty list, so the UI could not tell "no items yet" from "no items". A manual collect-and-republish also means hand-writing the cancellation.

"Add a memory cache in the repository." Treats the symptom and introduces a second source of truth. It also does not stop the analytics event or the cache write from running three times, because those are in the flow body.

"Make it a SharedFlow with replay = 1." Closer, and replay alone does not make it hot — and a bare MutableSharedFlow needs something to feed it, which is the collect-once problem again.

The actual mechanism

Flow is defined by one method:

interface Flow<out T> {
    suspend fun collect(collector: FlowCollector<T>)
}

That is the whole contract. A Flow is an object with a collect function, and flow { … } builds one whose collect runs your block. Nothing executes until someone calls collect, and every call runs it again.

This is deliberate and mostly what you want:

Laziness. Nothing happens for a flow nobody reads — no wasted work for a screen that never opened.

Per-collector context. Each collection gets its own coroutine, its own cancellation, and its own exception path. A failed collector does not poison another.

Composability. map, filter, combine build new recipes cheaply, because they build objects rather than start work.

The cost is that sharing is opt-in, and the default — three collectors, three executions — is invisible in code that reads like one pipeline.

One cold flow declaration collected by three subscribers, each re-running the producer block from the start: three network fetches, three cache writes and three analytics events from a single function — beside the same declaration behind shareIn, where one upstream execution fans out to all three

So:

A cold flow's work is per collector. The number of times your network call fires is the number of collect calls, not the number of flow declarations — and map/filter chains inherit the coldness of whatever is upstream.

Which also explains why side effects in a flow body are a design smell. cache.write and analytics.log are not idempotent-by-accident; they ran three times because they were placed in something defined to re-run.

And the sub-case that reintroduces the bug after you have fixed it: shareIn and stateIn take a SharingStarted policy, and the policy decides whether the upstream survives a brief gap in subscribers.

.stateIn(scope, SharingStarted.WhileSubscribed(), initial)        // cancels the instant the last
                                                                  // collector leaves
.stateIn(scope, SharingStarted.WhileSubscribed(5_000), initial)   // survives a 5s gap

On Android a configuration change tears down the collector and recreates it a few milliseconds later. With the first line, that gap cancels the shared upstream and the next subscriber restarts it — so rotating the device re-runs the network call, which is precisely the duplicate-work problem shareIn was added to solve, now triggered by rotation instead of by collector count.

The five-second timeout is the conventional answer because it comfortably exceeds a config change and stays well under the window where keeping a socket open starts to cost something. Eagerly avoids the issue entirely at the price of running for flows nobody is reading — which gives back the laziness that made cold flows worth having.

The general shape: sharing converts a per-collector cost into a lifetime question, and the policy is where you answer it. A default-constructed WhileSubscribed() is not a neutral choice; it is the most aggressive one.

The fix

Share once, in the ViewModel, with an explicit policy — and move the non-idempotent work out of the flow body.

// commonMain — the repository emits data and nothing else
class ReadingRepository(private val api: Api, private val cache: Cache) {

    fun items(): Flow<Loadable<List<Item>>> = flow {
        emit(Loadable.Loading(cache.read()))        // stale-while-revalidate
        val fresh = api.fetchItems()
        cache.write(fresh)                          // still here: part of fetching
        emit(Loadable.Ready(fresh))
    }
}
// commonMain — ONE upstream execution, fanned out
class ReadingViewModel(repo: ReadingRepository) {

    private val shared: StateFlow<Loadable<List<Item>>> =
        repo.items()
            .onEach { if (it is Loadable.Ready) analytics.log("items_fetched") }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = Loadable.Loading(emptyList()),
            )

    val items: StateFlow<Loadable<List<Item>>> = shared
    val badge: StateFlow<Int> = shared
        .map { it.valueOrEmpty().size }
        .stateIn(viewModelScope, SharingStarted.Eagerly, 0)
}

Four decisions, each load-bearing:

stateIn, not shareIn, for screen state. stateIn guarantees a current value, which is what a UI needs — a composable subscribing mid-flight gets the latest state rather than waiting for the next emission.

WhileSubscribed(5_000) is the rotation answer. With Eagerly, the upstream runs forever, including while the screen is backgrounded. With Lazily, it never stops once started. The five-second grace keeps the upstream alive across a configuration change or a quick navigation away, so a rotation does not re-fetch — and lets it stop when the user genuinely leaves.

badge derives from shared, not from repo.items(). This is the actual bug fix. Mapping the shared flow adds a cheap downstream transform; mapping the repository would have started a second upstream, which is exactly what we had.

The analytics moved to onEach above the share point. One execution of the upstream means one event. Side effects placed below a share point run per collector again — the position relative to stateIn is the thing that decides.

And Loadable instead of a bare list is what fixed the loading state I lost earlier: the UI can distinguish "loading, here is stale data" from "loaded, genuinely empty".

How we proved it

The claim is about execution counts, so the measurement is a counting fake — which is both the test and the proof.

// commonTest
@Test
fun oneUpstreamExecutionForManyCollectors() = runTest {
    val api = CountingApi()
    val vm = ReadingViewModel(ReadingRepository(api, fakeCache))

    // three independent subscribers, as the UI would have
    val a = launch { vm.items.collect { } }
    val b = launch { vm.badge.collect { } }
    val c = launch { vm.items.collect { } }
    advanceUntilIdle()

    assertEquals(1, api.fetchCount)
    listOf(a, b, c).forEach { it.cancel() }
}
before:  expected 1, got 3
after:   1

Then the rotation case, which is the one WhileSubscribed exists for and the one a simple test misses:

@Test
fun rotationWithinGraceDoesNotRefetch() = runTest {
    val job1 = launch { vm.items.collect { } }
    advanceUntilIdle()
    job1.cancel()                          // screen destroyed
    advanceTimeBy(1_000)                   // rotation gap
    val job2 = launch { vm.items.collect { } }
    advanceUntilIdle()
    assertEquals(1, api.fetchCount)        // ← 2 with Lazily/no grace
    job2.cancel()
}

And the production numbers after release:

                       before    after
GET /items per open      2.9       1.0
items_fetched / session   41        14      (= screens opened)

Two honest limits. advanceTimeBy uses virtual time, so this test verifies the policy under WhileSubscribed, not that a real 5-second window behaves identically under a real dispatcher — the number itself is a judgement call we did not measure against user behaviour. And one collector failing now affects the shared upstream differently from three independent ones; with stateIn an upstream exception cancels the sharing coroutine, so error handling belongs in the flow (catch) rather than in each collector.

What this generalizes to

"Cold" means the work is per consumer; "hot" means the work is per producer. Worth asking of any stream abstraction you adopt — RxJava's Observable versus Subject, a generator versus a queue, a lazy sequence versus a list. The question "how many times does the expensive thing run?" has a different answer for each, and it is rarely in the type name.

Storing a lazy thing in a variable does not make it eager. A Flow in a property, an unawaited promise factory, a lambda assigned to a val — the variable holds the recipe. If you want one execution, something has to actually run it once and hold the result.

Side effects belong where the execution count is known. analytics.log was wrong inside the flow body not because logging is impure but because the body's execution count was not what the author believed. Put effects where you can state how many times they run.

Derive downstream from the shared node, never from the source. Every map off the original source is a new execution. This is the single most common version of this bug, and it looks identical to the correct code.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS