Flow back-pressure: sequential by default, and what buffer() buys
emit() does not return until your collector has finished. That one fact explains every Flow performance question you will ever be asked.
Explore the full explanation 8 ideas
The most important property of a `Flow` is the least advertised one: it is **sequential**. `emit(x)` is a suspending call that does not return until the collector's lambda has finished processing `x`. Producer and consumer therefore alternate — produce, consume, produce, consume — and the total time for `n` elements is `n * (produce + consume)`. There is no thread pool underneath doing anything clever, and no hidden queue. That is why a Flow needs no synchronisation and why its exception handling is simple; it is also why a naive pipeline can be twice as slow as it needs to be.
`buffer()` is the operator that breaks the alternation. It inserts a channel between the two stages, so the producer runs ahead into the buffer while the collector works, and the total becomes bounded by the *slower* stage rather than by their sum. With four items, 30 ms to produce and 70 ms to consume, sequential costs 400 ms and buffered costs 310 — `produce + n * consume`. Every value is kept; what you pay is memory, and `buffer(n)` lets you say how much. This is the single highest-leverage operator in Flow, and it is one word.
`conflate()` is `buffer(CONFLATED)`: a one-slot buffer where a newer value overwrites an older one. The producer never waits, memory is bounded at one element, and the price is that values the collector was too busy for are **skipped entirely** — they are never started. For a UI rendering the latest state, a dashboard, a price ticker, that is exactly right: a value that has already been superseded is not worth the work. For anything you must not lose, it is data loss with a friendly name.
`collectLatest { }` looks similar and is fundamentally different: instead of skipping values you have not started, it **cancels the work in progress** when a newer value arrives. Only the last value ever reaches the end of the block. That is the operator for stale work — search-as-you-type, a screen re-rendering from the newest state, any request whose result is worthless the moment a newer input exists. Because cancellation is cooperative, the body must actually suspend for this to work: a body doing a long blocking computation is never cancelled, and `collectLatest` silently degrades into a plain `collect`.
`flowOn(dispatcher)` changes where the **upstream** runs. Everything above it in the chain executes on that dispatcher; everything below stays where the collector is. It is the only correct way to move producer work off the current thread, because emitting from a different context directly is illegal (that is the context-preservation rule of the Flow contract). Two things about it surprise people: it applies only upstream — position in the chain matters — and it introduces a buffer of its own, so moving a stage to another dispatcher can change your timings even though you never wrote `buffer()`.
Concurrency inside a Flow is opt-in and explicit. `map { }` is sequential by construction. When each element needs an independent asynchronous call, `flatMapMerge(concurrency = k) { flow { … } }` runs up to `k` inner flows at once and interleaves their results; `flatMapConcat` keeps source order at the cost of parallelism; `flatMapLatest` cancels the previous inner flow whenever a new element arrives. Those three are the same three answers `flatMap`, `concatMap` and `switchMap` give in Reactor, and choosing between them is choosing between throughput, ordering and staleness.
Operator fusion is why this stays cheap. Adjacent `flowOn`, `buffer` and `conflate` operators are collapsed by the runtime into a single channel with a single configuration, so writing `.flowOn(IO).buffer()` does not create two queues. It also means `buffer()` placement is meaningful in a way that `map` placement is not: a buffer inserted before an expensive stage decouples that stage from what is upstream of it, and buffers at both ends of a slow middle stage let all three run concurrently.
A practical checklist. Measure before adding a buffer — under `runTest`, `currentTime` gives you exact virtual timings, so "is the collector the bottleneck?" is a question with a number rather than an opinion. Add `buffer()` when both stages are genuinely slow and you must keep every value. Use `conflate()` when only the latest value matters and the work per value is short. Use `collectLatest` when in-flight work becomes worthless on a newer input. And leave it sequential when it is already fast enough, because sequential is the version with no queue, no memory growth and no surprises.
Let’s figure it out.
Ask me about this course or where to start.
import kotlinx.coroutines.*import kotlinx.coroutines.flow.*import kotlinx.coroutines.test.*@OptIn(ExperimentalCoroutinesApi::class)fun main() = runTest {val n = 4L; val produce = 30L; val consume = 70Lfun src() = flow { for (i in 1..n) { delay(produce); emit(i) } }var t = currentTimeval a = mutableListOf<Long>()src().collect { delay(consume); a.add(it) }println("sequential: " + a.size + " items in " + (currentTime - t) + "ms")t = currentTimeval b = mutableListOf<Long>()src().buffer().collect { delay(consume); b.add(it) }println("buffered: " + b.size + " items in " + (currentTime - t) + "ms")t = currentTimeval c = mutableListOf<Long>()src().conflate().collect { delay(consume); c.add(it) }println("conflated: " + c.size + " items in " + (currentTime - t) + "ms")}
The same pipeline measured three ways on runTest's virtual clock. The numbers are exact, not approximate — delay() costs no wall-clock time and currentTime reports the virtual instant.
Arena IDEimport kotlinx.coroutines.*import kotlinx.coroutines.flow.*import kotlinx.coroutines.test.*@OptIn(ExperimentalCoroutinesApi::class)fun main() = runTest {fun src() = flow { for (i in 1..5) { delay(10); emit(i) } }val plain = mutableListOf<Int>()src().collect { delay(35); plain.add(it) }println("plain = " + plain)val conflated = mutableListOf<Int>()src().conflate().collect { delay(35); conflated.add(it) }println("conflate = " + conflated) // skipped, never startedval latest = mutableListOf<Int>()src().collectLatest { delay(35); latest.add(it) }println("collectLatest = " + latest) // cancelled mid-flight}
Skip versus abandon. conflate() never STARTS the values it skipped; collectLatest CANCELS the ones it started, so only the final value survives to the end of the block.
Arena IDEimport kotlinx.coroutines.*import kotlinx.coroutines.flow.*import kotlinx.coroutines.test.*@OptIn(ExperimentalCoroutinesApi::class)fun main() = runTest {// Later ids are FASTER, so ordering differences become visible.fun fetch(id: Int) = flow { delay((4 - id) * 50L); emit("r" + id) }val ids = flowOf(1, 2, 3)var t = currentTimeprintln("concat = " + ids.flatMapConcat { fetch(it) }.toList() + " in " + (currentTime - t))t = currentTimeprintln("merge = " + ids.flatMapMerge(concurrency = 3) { fetch(it) }.toList() + " in " + (currentTime - t))t = currentTimeprintln("latest = " + ids.flatMapLatest { fetch(it) }.toList() + " in " + (currentTime - t))}
Concurrency is opt-in: flatMapConcat keeps order, flatMapMerge(k) runs k inner flows at once, flatMapLatest cancels the previous one. The elapsed times tell you which is which.
Arena IDEimport kotlinx.coroutines.*import kotlinx.coroutines.flow.*fun main() = runBlocking {val names = mutableListOf<String>()flow {names.add("produce on " + Thread.currentThread().name.substringBefore('@').trim())emit(1)}.map { it * 2 } // still upstream: moved too.flowOn(Dispatchers.Default) // everything ABOVE runs on Default.collect {names.add("collect on " + Thread.currentThread().name.substringBefore('@').trim())}// Producer on the Default pool, collector back on the caller's context.println(names.joinToString(" | "))}
flowOn moves only what is ABOVE it. Position is the whole API — and note it also inserts a buffer, which is why a dispatcher change can alter your timings on its own.
Arena IDE🧠 Check your understanding
0/1 · 0/1 answered1. A Flow produces an item every 30 ms and the collector takes 70 ms per item. You add `.buffer()`. What changes?