If a coroutine answers "run this work," a Flow answers "stream this data." A coroutine produces one result (or none, or throws); a Flow produces a sequence of values over time — a live database query, a progress tick, a search box that emits as the user types. Flow is Kotlin's reactive-streams library, rebuilt on the coroutine machinery you already know, so it inherits cancellation and structured concurrency for free.
The three parts of every Flow
Every Flow is the same three pieces wired together. Understand these and every operator is just an instance of one of them:
- A builder produces values — `flow { emit(x) }` for imperative logic, `flowOf(1, 2, 3)` for a fixed set, `asFlow()` to lift a collection, `callbackFlow` to bridge a callback API that doesn't know about suspend
- Operators transform the stream between builder and collector — `map`, `filter`, `flatMapLatest`, `debounce`, `zip`, `retry`. Operators are themselves just flows that collect the upstream and emit downstream
- A terminal operator collects — `collect { }`, `toList()`, `first()`, `reduce()`. Nothing runs until a terminal operator subscribes
Cold vs hot: why nothing happens until you collect
A `flow { }` is cold: the block inside doesn't run when you build the flow, only when something collects it — and it runs once per collector. This is the property that makes Flow easy to reason about: no values are being produced in the background unless a consumer is actually listening.
- Cold flow — the producer starts fresh for each collector, so two collectors get two independent runs (and two independent network calls, if the flow does one)
- `StateFlow` and `SharedFlow` are hot — they emit whether or not anyone is collecting, and late collectors see only the latest value, not a replay of history
- `StateFlow` is the hot flow for UI state: it holds one value, conflates rapid updates, and always has a current value. A ViewModel exposes `StateFlow<UiState>` and the UI collects it
- Backpressure is the default: a slow collector makes the producer wait. Use `buffer`, `conflate`, or `collectLatest` to say explicitly how you want to handle a producer that outpaces the collector
Operators you'll actually reach for
Flow ships a large operator set; a small handful covers almost everything real apps need, and they map cleanly onto the async problems of an Android app:
- `map` / `filter` — transform and filter each value (the same shape as collections, which is the point)
- `zip` — combine two flows pairwise; the standard way to run two independent network calls in parallel and act when both return
- `flatMapConcat` / `flatMapLatest` — flatten a flow-of-flows: concat runs them in series, latest cancels the previous when a new value arrives (the engine behind instant search)
- `debounce` + `distinctUntilChanged` — wait for a pause in input, then drop consecutive duplicates; together with `flatMapLatest` this is the entire "search as you type" recipe
- `catch` — intercept an upstream exception and emit a fallback instead of letting it propagate
- `retry` / `retryWhen` — re-subscribe to the upstream on failure, with optional exponential backoff
- `onCompletion` — run cleanup or a final side effect whether the flow completes normally or with an error
Why Flow over RxJava (and when not to bother)
RxJava has its own scheduler, its own cancellation model, and its own concept of a stream — a whole parallel universe layered on top of Kotlin. Flow deliberately reuses coroutines for all of that, so a Flow is cancelled the same way a coroutine is, run on the same dispatchers, and understood by the same tools. For greenfield Kotlin, that cohesion is the entire argument. The counter-argument: if a codebase already commits to RxJava's ecosystem (composed operators, a specific scheduler model, a large operator surface), ripping it out is a cost with no immediate payoff — Flow isn't strictly more capable, just more native.
In this note
Prefer it hands-on?
This note has a matching interactive topic with diagrams and a runnable repo.