5 min read

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.

KotlinCoroutinesFlowAndroidConcurrency
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DRflowOn 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:

flowOn reaches 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.

Keep reading