Flow Operators That Quietly Change Your Threading
flowOn affects everything upstream of it and nothing downstream — the opposite of RxJava's observeOn. Why operator order silently moves work onto the main thread, why there's no collectOn, and the mental model that stops the guessing.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — flowOn changes the dispatcher for operators above it in the chain, never below.
Moving it one line changes which thread your expensive map runs on, with no compiler complaint. There
is no collectOn because the terminal operator always runs in the collector's context — that's
structured concurrency, not an omission. Read a Flow chain bottom-up: it's built top-down and
executes bottom-up.
The question that catches almost everyone
repo.stream()
.map { expensive(it) }
.flowOn(Dispatchers.IO)
.collect { render(it) }
map runs on Dispatchers.IO. collect runs in whatever context called collect — typically
Dispatchers.Main from a ViewModel. That's correct and it's what you wanted.
Now move one line:
repo.stream()
.flowOn(Dispatchers.IO) // governs only repo.stream()
.map { expensive(it) } // runs in the COLLECTOR's context — main thread
.collect { render(it) }
Identical operators, identical dispatchers, and this version janks. Nothing warns you.
Why: context preservation
Flow guarantees that a value is emitted in the same context the collector runs in, unless you
explicitly say otherwise. flowOn is that explicit statement — and it applies to the part of the chain
it can still influence, which is everything upstream.
Downstream operators have already been "collected into" by the time flowOn is applied, so it has no
power over them. Hence the rule:
flowOnreaches up, never down.
If you're coming from RxJava, this is the exact inverse of observeOn, which affects everything
downstream. That single inversion accounts for most of the confusion I see in code review, and it's
why the mistake clusters in teams that migrated from Rx.
There is no collectOn
People look for one. Its absence is deliberate: the terminal operator runs in the caller's coroutine
context, because that's what makes cancellation and structured concurrency work. If collect could
hop contexts, the collector's lifetime would stop being the collector's business.
If you want collection on a different dispatcher, change the context you collect in:
viewModelScope.launch(Dispatchers.Default) {
flow.collect { /* runs on Default */ }
}
That's explicit about the scope and lifetime, which is the point.
Multiple flowOn calls stack in segments
repo.stream() // IO
.map { parse(it) } // IO
.flowOn(Dispatchers.IO)
.map { computeLayout(it) } // Default
.flowOn(Dispatchers.Default)
.collect { render(it) } // caller's context (Main)
Each flowOn governs the operators above it, up to the previous flowOn. Reading this correctly
requires going bottom-up, which is unnatural for most people the first time — and is exactly why these
chains get misread in review.
withContext inside collect is usually a smell
.collect { item ->
withContext(Dispatchers.Default) { heavy(item) } // suspends the collector per item
render(item)
}
This works, but the heavy work is now inside the terminal operator: it blocks collection of the next item, backpressure behaviour becomes implicit, and cancellation is coarser than it looks.
Move the work upstream where it's just another operator:
repo.stream()
.map { heavy(it) }
.flowOn(Dispatchers.Default)
.collect { render(it) }
Now buffering, conflation and cancellation all apply to it normally.
The other operator that moves work: buffer
repo.stream()
.buffer() // upstream now runs concurrently with downstream
.map { render(it) }
buffer doesn't change the dispatcher, but it does change concurrency: producer and consumer stop
running in lockstep. That's often what you want for a slow collector, and it's also how you turn a
sequential chain into one with two things in flight — worth knowing before you debug an ordering
surprise.
conflate() and collectLatest similarly change which values survive, not which thread runs them.
Threading and backpressure are separate axes, and conflating them (no pun intended) is the second most
common Flow misunderstanding after flowOn.
The mental model
A Flow is built top-down and executes bottom-up.
collect pulls; each operator asks the one above it for values. flowOn reaches up and changes the
context of the part of the chain above it. Once you read chains that way, operator order stops being
something you memorise and starts being something you derive.
And when in doubt, don't reason — measure. Thread.currentThread().name in a map answers the
question in ten seconds, and a Perfetto trace shows you whether the work landed on the main thread
regardless of what you intended.