1. Introduction
When you first see Flow, your brain honestly asks: “Why another thing if we already have Channel? We just learned how to send, receive, close—even enable buffering!” This is a normal reaction: programmers generally tend to think the world already has enough new entities, especially on Monday morning.
The difference is in what exactly you are modeling. Channel is primarily communication: coroutines “toss” values to each other like a ball. And Flow is a description of the process of obtaining values: “here is how values will appear, and here is how you can take them.” Kotlin docs put this very directly: Flow produces values only when it is actively collected, while Channel allows coroutines to send and receive values, and each value is delivered to exactly one receiver.
To feel the difference, a everyday analogy helps. Channel is like a mailbox (or a service queue): someone drops letters in, someone picks them up, letters accumulate (if there is room), and it matters to “close intake” so everyone understands there will be no new letters. Flow is more like a tour script: “first we’ll show hall No. 1, then hall No. 2…,” but the tour actually starts only when visitors arrive and say: “go ahead, lead us!”
2. What Flow<T> is: a stream description, not a data container
When we say val list = listOf(1, 2, 3), we are holding data (the container is already filled). When we say val channel = Channel<Int>(), we are holding a communication mechanism: something can be sent there, and something can be received from there. But val f: Flow<Int> = ... is more like a contract: “here is where and how the numbers will come from.”
And here is an important mental switch. Flow is not a “queue of values” where they already sit and wait for you. It is more like a function: you describe how they will appear, and execution starts later.
Flow has two key words you need to tame today:
- flow { ... } — a builder that creates a Flow.
- collect { ... } — a collector (a terminal operation) that starts the stream execution and receives values.
And one more principle worth keeping in mind from the very beginning: without collect, “nothing happens.” This is not a metaphor or philosophy; it is literally Flow behavior: until you collect, the stream does not start.
3. Minimal Flow: flow { emit() } and collect { ... }
Now we will write the smallest example that can be called “hello, Flow.” It will have no transformation operators (that’s the next lecture), just a source of values and a collector.
Pay attention to the structure: collect is suspend, so it must be called in a coroutine context (for example, inside runBlocking, which we already used in the coroutine lectures).
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.collect
fun main() = runBlocking {
val numbers = flow {
emit(1)
emit(2)
emit(3)
}
numbers.collect { v ->
println("v=$v") // v=1, then v=2, then v=3
}
}
It is important to see two “layers” here.
Inside flow { ... } we describe how values will appear: “emit 1, then 2, then 3.”
And inside collect { ... } we describe what to do with each value when it appears: “print it.”
You do not see any close()—because Flow completion looks different: the stream ends when the code in flow { ... } ends. That is, “end of the stream” is simply “we reached the end of the block.”
4. Cold behavior of Flow in practice
From the previous examples it may seem that Flow is just “another way to iterate 1, 2, 3.” But the real power starts when values appear not instantly, but over time. And here Flow naturally plays well with coroutines: you can do delay between emit.
delay inside flow: values appear over time
This is a good place to feel: Flow is a sequence of values stretched over time, but it is still collected sequentially (within a single collect).
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.collect
fun main() = runBlocking {
val ticks = flow {
emit("tick")
delay(200)
emit("tock")
delay(200)
emit("done")
}
ticks.collect { word ->
println(word) // tick, then tock, then done
}
}
You can think of this as a “small generator”: it emits a value, then waits, then emits another. And all of this happens only because someone called collect.
Why Flow is called a cold stream
The word cold sounds like the stream is offended at you and refuses to heat the radiators. In practice the meaning is simpler: no consumer—no production. If nobody collects values, flow { ... } will not even start executing.
Let’s show this as honestly as possible: add println inside flow { ... } to see the moment it starts.
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.collect
fun main() = runBlocking {
val f = flow {
println("Flow started") // we will see this only during collect
emit(10)
}
println("Before collect") // Before collect
f.collect { println("v=$it") } // Flow started; v=10
}
If you run it, you will see that the "Flow started" line appears after "Before collect", i.e., really during collect, not at the moment of creating f.
And this is the central idea. With Channel, you can start sending values “into nowhere” (and then someone will receive them) if there is a buffer or if the sender is willing to suspend. But Flow is not a queue and not a “storage place.” It does not “accumulate” anything by itself: it executes.
Two collect calls—two runs
Now let’s do a small experiment: collect the same Flow twice. If you are used to collections, you might expect “we collected it, and the second time we’ll just iterate over the same data again.” But a cold stream has different logic: each collect starts execution from scratch.
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.collect
fun main() = runBlocking {
val f = flow {
println("Producing...")
emit(1)
}
f.collect { println("first collect: $it") }
// Producing...
// first collect: 1
f.collect { println("second collect: $it") }
// Producing...
// second collect: 1
}
Here “Producing...” is printed twice—that is the practical meaning of a cold stream.
A useful thought to cement this: Flow is closer to a “recipe” (you can cook it many times), while Channel is closer to “a pot of soup” (once it’s poured in, whoever gets there first eats it).
5. Flow and Channel: choosing the right model
Now comes an important part: we will not just list differences, but connect them to what problem you are solving. And it helps to keep a “world model” in mind.
Channel models a message queue between coroutines: “someone sent, someone received.” Kotlin docs phrase it like this: a channel lets coroutines send and receive values, and each value is delivered to exactly one coroutine. This directly hints at distributing work among multiple consumers.
Flow models a stream of values over time: “a source emits values, and a consumer collects them.” And the key point: Flow emits values only when it is collected. So it naturally reads as “source → collection.”
To make it easier to compare without inner pain, let’s summarize the main differences in a table:
| Topic | Channel | Flow |
|---|---|---|
| Main idea | Communication: pass a message between coroutines | Stream of values: describe how values appear over time |
| Start | The sender can start send immediately (but may suspend) | The source starts only on collect (cold) |
| “End of data” | Usually you need close() as a completion signal | Completion = reaching the end of the flow { ... } block |
| Multiple consumers | One value is received by only one receiver | Each collect starts the stream again (in the general case) |
| What it resembles | Queue/mail/task feed | Generator/recipe/script for producing values |
Here is a typical practical selection criterion (without fanaticism). If you are designing a system like “there are workers, we need to distribute tasks among them,” a channel often feels more natural. If you are designing a system like “there is a data source, I want to sequentially obtain values and process them,” Flow usually reads simpler and more “honestly” expresses the intent.
Why you cannot do send in Flow (and why that is OK)
Sometimes a beginner tries to use Flow like a channel: “so how do I do send now?” The answer is simple: you can’t. And it is not because Kotlin developers “forgot to add a method,” but because this is a different tool for a different job.
Flow is a stream from a source to a consumer. It has no shared “box” where you can toss messages from different places. This is a deliberate constraint: it forces you to keep the architecture cleaner. The source is described in one place, collection is in another, and you do not get “accidental” sends from random corners of the program that are then impossible to trace.
If you need communication like “many coroutines send, many coroutines receive”—that is channel territory. If you need the model “values arrive over time, I collect them”—Flow will feel natural.
6. Mini event monitor: Flow in a console app
So the examples do not live in a vacuum, let’s keep building a small app we already mentally used with channels: a console event monitor. Before, we could pass events via Channel<String>, and now we will make a version where the event source is a Flow<String>.
We are not doing networking, files, or anything “external” (that would be extra today). We just simulate events: “app started,” “connection established,” “running,” “shutdown.”
A function that returns Flow
Let’s start with a small function that creates the stream. It is important to get used to this: unlike Channel, where you create an object and then “push” into it, with Flow you more often write a function that describes “how to obtain values.”
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
fun buildStatusFlow(): Flow<String> = flow {
emit("START")
delay(150)
emit("CONNECTED")
delay(150)
emit("RUNNING")
delay(150)
emit("STOP")
}
Here we neatly wrote a “recipe” of statuses. It prints nothing and starts nobody—it just returns a Flow<String>.
collect in main: run and print
Now let’s use it in main. Yes, runBlocking again: we are in a console app, and collect is suspend, which means we need a coroutine context.
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.collect
fun main() = runBlocking {
val statusFlow = buildStatusFlow()
statusFlow.collect { status ->
println("status=$status")
// status=START, then CONNECTED, RUNNING, STOP
}
}
Notice an important discipline: the stream describes values, and main decides what to do with them. This makes the code more predictable: the source does not print “secretly” (unless you want it to), and you do not lose control.
Cold behavior demo: collect one stream twice
And to finally hammer the cold nature into memory, you can add a small “repeat mode”: collect the same stream twice.
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.collect
fun main() = runBlocking {
val statusFlow = buildStatusFlow()
println("First run:")
statusFlow.collect { println(it) } // START ... STOP
println("Second run:")
statusFlow.collect { println(it) } // START ... STOP (again)
}
If you saw the statuses repeat—congrats, you just felt a cold stream, not just read a definition.
7. Common mistakes when first getting to know Flow
Mistake No. 1: expecting Flow to start working by itself.
A very common trap is to write val f = flow { emit(1) } and expect “well, it’s created, so somewhere in there the 1 has already started.” No: a cold stream will not execute until you explicitly call collect. If you see “nothing is printed,” first check whether there is a collector.
Mistake No. 2: confusing Flow with a message queue and looking for send.
If you mechanically “translate” your code from Channel to Flow, you will quickly hit the question “where do I send to?” The answer: Flow is not about “sending,” it is about “emitting.” Usually that means instead of “create a channel and hand it to everyone,” you write a function that returns a Flow and describe value production inside flow { ... }.
Mistake No. 3: forgetting that collect is suspend and trying to call it from a regular function.
This looks like the typical compiler error: “Suspend function 'collect' should be called only from a coroutine or another suspend function”. It is fixed by exactly what you already know: either call collect inside runBlocking (in a console main), or make your function suspend and call it from a coroutine.
Mistake No. 4: being surprised that two collect calls do the work twice.
Beginners often perceive Flow as “a list, just asynchronous.” So a repeated collect feels like “re-reading the same data.” In reality it is a rerun. That can be useful (you can reuse the stream as a recipe), but if you did not expect it, you may accidentally execute expensive work twice. At the start of the course, it is enough to remember the rule: a cold stream usually restarts per collection.
Mistake No. 5: mixing responsibilities of the source and the consumer.
When the code inside flow { ... } starts printing to the console, mutating global variables, and doing “half an app,” the code quickly becomes muddy: it is unclear who controls the output and when things happen. In the first steps it is better to keep a familiar discipline: the source does emit, the consumer does collect and decides what to print and how to react. This makes behavior clearer, especially when you later start collecting longer pipelines.