When you call toList() on a flow, on which coroutine/dispatcher does the upstream producer run, and what happens to collection if the surrounding coroutine is cancelled mid-stream?
answer
- Context preservation: producer runs in collector's context
- flowOn changes UPSTREAM context only; terminal stays put
- toList = collect into a MutableList
- Cancellation is cooperative at suspension points
- Cancelled mid-stream -> CancellationException, partial list dropped
basics
~10 sBy default the producer runs in the same context as the collector that called toList(). If that coroutine is cancelled while collecting, collection stops with a CancellationException and toList() does not return.
solid answer
~40 stoList() (like all terminal operators) collects in the caller's coroutine context — Flow obeys 'context preservation': the flow { } producer runs in the same context as the terminal unless flowOn(dispatcher) is used to change the UPSTREAM context. So without flowOn, emit and collect share one dispatcher; with flowOn(Dispatchers.IO) the upstream runs on IO while toList() still assembles the list in the caller's context. Cancellation is cooperative: terminal operators are cancellable at suspension points, and most flow builders/operators check for cancellation. If the collecting coroutine is cancelled mid-stream, the in-progress collect/toList throws CancellationException, the upstream is cancelled, and the partial list is discarded (toList never returns it). Note flowOf/asFlow are cancellable on emission, while a tight loop emitting without suspension may need an explicit cancellation check.
code
kotlin · 10 linessuspend fun loadAll(): List<Row> =
repository.streamRows() // emits while hitting the DB
.flowOn(Dispatchers.IO) // upstream on IO
.toList() // list assembled in caller's context
// cancellation
val job = scope.launch {
val rows = loadAll() // if scope is cancelled mid-collect,
render(rows) // toList() throws -> render() never runs
}go deeper
Knows toList gathers all elements and that cancelling stops collection.
States that the producer runs in the collector's context by default and that flowOn changes upstream context.
Explains context preservation, the cooperative cancellation model, and that toList drops the partial list on cancellation.
Reasons about dispatcher placement for blocking producers, the flow-invariant violation with withContext, non-cancellable CPU loops, and structured-concurrency exception hygiene across layers.
## 1. Context preservation — where does the producer run? Flow guarantees **context preservation**: the producer block (`flow { emit(...) }`) runs in the **same `CoroutineContext` as the terminal operator** that collects it. So: ```kotlin withContext(Dispatchers.Default) { flow { emit(1) }.toList() // emit runs on Dispatchers.Default too } ``` You must **not** change context inside `flow { }` with `withContext` — that throws an `IllegalStateException` ('Flow invariant is violated'). To run the **upstream** on a different dispatcher, use the dedicated operator: ```kotlin flow { heavyProduce() } // runs on IO .flowOn(Dispatchers.IO) .toList() // the list is assembled in the COLLECTOR's context ``` `flowOn` changes the context of everything **upstream** of it only; the terminal (`toList`) stays in the caller's context. ## 2. toList implementation `toList()` is just `collect` into a `MutableList`: ```kotlin suspend fun <T> Flow<T>.toList(destination: MutableList<T> = ArrayList()): List<T> { collect { destination.add(it) } return destination } ``` So everything true of `collect` (context, cancellation) is true of `toList`/`toSet`/`count` etc. ## 3. Cancellation is cooperative Coroutine cancellation in Kotlin is **cooperative**: code keeps running until it hits a **cancellable suspension point**. Flow integrates with this: - Built-in builders like `flowOf`, `asFlow`, and operators insert cancellation checks (`flowOf` became cancellable on each emission since coroutines 1.3.x via `ensureActive`). - When the **collecting coroutine** is cancelled, the next suspension/emission throws `CancellationException`, which: - propagates up, **cancelling the upstream** producer, - causes `toList()` to throw (it does **not** return the partial list), - is rethrown by the terminal — you generally must let it propagate (don't swallow `CancellationException`). ```kotlin val job = launch { val all = veryLongFlow.toList() // never assigned if cancelled use(all) } delay(50) job.cancel() // toList throws CancellationException; partial list dropped ``` ## 4. Non-cancellable tight loops A producer that emits in a tight CPU loop **without suspending** may not observe cancellation promptly: ```kotlin flow { var i = 0; while (true) emit(i++) } // emit checks active since 1.3+, so OK flow { while (true) { /* no emit, no suspend */ } } // would NOT cancel ``` For pure-CPU loops without emissions, add `ensureActive()` / `yield()` or use `.cancellable()`. ## 5. Practical implications - Wrap blocking producers with `flowOn(Dispatchers.IO)` so `toList` doesn't block the caller's dispatcher. - Treat `CancellationException` from a terminal as normal structured-concurrency shutdown — rethrow it; never catch-and-ignore. - Use `firstOrNull`/`first` instead of `toList` when you don't need every element, so cancellation/short-circuit is cheaper.
- Why can't you use withContext inside a flow { } builder to switch dispatchers?It violates Flow's context-preservation invariant and throws IllegalStateException at runtime. Use the flowOn operator instead, which is designed to change upstream context safely.
- If a flow's producer is a tight CPU loop with no emissions or suspensions, will cancelling the collector stop it?Not necessarily — cancellation is cooperative and only observed at suspension/cancellation points. Add yield()/ensureActive() or use .cancellable() so it can be interrupted.
flowOn is like sending the kitchen (producer) to another building; the waiter (terminal toList) still serves you in your room. Pull the fire alarm (cancel) and both stop, and you get no plated dish.
saying these in an interview costs you the question
- Saying toList runs the producer on a fixed/background dispatcher by default
- Claiming flowOn moves the terminal operator's context
- Catching and swallowing CancellationException from a terminal
- Believing toList returns the partial list after cancellation
- Using withContext inside flow { } to change dispatcher