skip to content

A teammate creates a Flow that logs and makes an HTTP call, but the logs never appear. The Flow is never collected. Explain why, and how coldness should shape where you put side effects.

level: seniorimportance: should knowfreq 45%

answer

  1. Uncollected = nothing runs (logs/HTTP silent)
  2. Effects inside flow are deferred + repeated per collect
  3. launchIn / collect = terminal trigger
  4. Fire-and-forget? use suspend fun or launchIn
  5. Many collectors -> go hot (shareIn) to avoid duplicate effects

basics

~10 s

The Flow is cold, so its code only runs on collect. Since nobody collects it, the logging and HTTP call never execute. Side effects belong inside the flow and only fire when collected.

solid answer

~40 s

Because Flow is cold, the flow {} block is a deferred recipe that executes only when a terminal operator collects it. An uncollected Flow runs none of its body — no logs, no HTTP. This is a feature, not a bug: it lets you build and compose pipelines without triggering work. The design implication is that side effects placed inside flow {} (or in onEach/onStart/onCompletion) are deferred to collection time and repeated on every collect. So you must (a) ensure something actually collects the flow, (b) avoid putting eager side effects outside the flow expecting them to run, and (c) be aware that multiple collectors duplicate those side effects unless you go hot via shareIn/stateIn. For one-shot fire-and-forget side effects, prefer launchIn(scope) or a direct suspend call rather than relying on an unobserved Flow.

code

kotlin · 7 lines
kotlin
fun analytics(): Flow<Event> = flow {
    log.info("sending")      // runs only on collect
    emit(client.send(event))
}

analytics()                  // BUG: never runs
analytics().launchIn(scope)  // FIX: terminal operator drives it

go deeper

for a junior

Identifies that the flow was never collected so nothing ran.

for a middle

Explains deferral and that effects repeat per collect; knows collect/launchIn trigger it.

for a senior

Advises where side effects belong, picks suspend vs Flow, and uses shareIn to avoid duplicate effects.

for a principal

Sets team conventions for effect placement, idempotency, and lifecycle-aware collection to prevent subtle resource bugs.

## The diagnosis The Flow is **cold**: the body of `flow { ... }` and lazy operators like `onEach`/`map` run only when a **terminal operator** (`collect`, `launchIn`, `toList`, …) pulls the stream. If the Flow is never collected, its body never executes — hence no logs and no HTTP call. The work was *described* but never *performed*. ```kotlin fun track(): Flow<Unit> = flow { log.info("tracking") // never prints... httpClient.post(...) // never sent... emit(Unit) } track() // returns a cold Flow; nothing happens // fix: track().collect { } or track().launchIn(scope) ``` ## Coldness shapes where side effects go 1. **Side effects inside the flow are deferred and repeated.** Anything in `flow {}`, `onStart {}`, `onEach {}`, `onCompletion {}` runs at collection time and **once per collection**. Two collectors = two HTTP calls. 2. **Don't rely on an unobserved Flow to do work.** Returning a Flow for a fire-and-forget action is a trap: nothing runs until someone collects. For one-shot effects prefer a plain `suspend fun`, or explicitly drive it with `launchIn`: ```kotlin track() .onEach { /* effect */ } .launchIn(viewModelScope) // terminal: actually runs in the scope ``` 3. **If many collectors must share one execution, convert to hot.** `shareIn`/`stateIn` keep a single upstream run and broadcast, preventing duplicated side effects. 4. **Keep effects idempotent or guard them**, because re-collection (retries, reconnections, screen rotations re-subscribing) will re-trigger them. ## Operators that are still lazy `onStart`, `onCompletion`, `catch`, `onEach`, `flowOn`, `retry` are all intermediate — they wrap the cold flow and execute only under a terminal operator. So even adding `onStart { log(...) }` won't log unless someone collects. ## Mental model Treat a Flow as a **pure value describing a computation**. Building it is free and side-effect-free; running it is what `collect`/`launchIn` does. Put effects inside the flow when you want them tied to collection lifecycle; put one-shot effects outside the flow (or in a suspend function) when they must run unconditionally.

  • Is launchIn a terminal or intermediate operator?
    Terminal — it calls collect under the hood in the given scope and returns a Job, so it actually starts the flow.
  • If you only need one fire-and-forget HTTP call, is a Flow the right tool?
    Usually no. A plain suspend function (or launch { } around it) is clearer; a Flow's laziness adds a trap where nothing runs unless collected.
  • Will adding onStart { log() } make the logs appear without collecting?
    No. onStart is an intermediate operator; it still only runs when a terminal operator collects the chain.

Writing a to-do list (the Flow) doesn't do the chores; only working through it (collecting) does.

saying these in an interview costs you the question

  • Assuming flow {} runs eagerly so 'the log must be elsewhere'
  • Putting one-shot side effects in an uncollected Flow
  • Forgetting that collection repeats side effects
  • Thinking onStart/onEach run without a terminal operator
  • Reaching for Flow when a suspend fun suffices

context