1. Introduction
If you previously wrote only sequential code, an exception there is usually felt like an “unpleasant surprise.” In coroutines, it’s more like rain: unpleasant, but if you live in a city, an umbrella should be nearby. Plus, “cancellation” appears: we deliberately tell a coroutine “stop, no need to continue”—and that’s a normal scenario, not an accident.
In coroutine systems, one idea matters: Channel and Flow are not just APIs, but mini-protocols. A protocol has completion rules, places where an error must become visible, and a place where cleanup must happen. If you forget these three things, you get two classic “beginner symptoms”: the program hangs “forever,” or the program crashes “somewhere out there,” and you don’t understand why.
To ground the idea, let’s very briefly recall the difference: Channel delivers each message to exactly one receiver, while Flow produces values only when it is collected (collect). This difference directly affects where and how errors surface and what correct completion looks like.
2. Channel: completion, errors, and cleanup
Where errors “live” and why hanging is also an error
When you work with a Channel, you almost always have two roles: someone sends (send), someone receives (receive or for (x in ch)). And both operations are suspend, meaning they can wait. That’s exactly why channels are the champions of hangs: if no one sends a value and the channel is not closed, then receive() will wait. And it will wait for a long time. Sometimes for the entire lifetime of your program, which is dramatic and mildly annoying.
Errors in a channel can be “regular” (your error("boom"), IllegalStateException, division by zero, etc.), and they can be protocol-level: trying to send to a closed channel or trying to receive from a closed channel via receive(). Protocol-level errors are especially tricky because they often show up “not where” you expected the problem to be.
Let’s start with a small example that shows: if you closed a channel and then try to send—it’s already an exception, not “well, Kotlin will figure it out.”
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.channels.Channel
fun main() = runBlocking {
val ch = Channel<Int>()
ch.close()
try {
ch.send(1)
} catch (e: Throwable) {
println("Send failed: ${e::class.simpleName}") // ClosedSendChannelException (approximately)
}
}
What matters here is not the exact exception class (it can vary in details across different versions of kotlinx.coroutines), but the meaning: “closed” means the protocol said “there will be no more values,” and sending is not allowed.
Now the second point: reading. If you do receive() on a closed channel, you will also get an exception. That’s why consumers more often use the for (x in ch) loop, because it completes normally when the channel is closed and elements are exhausted—without an exception.
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
fun main() = runBlocking {
val ch = Channel<Int>()
launch {
ch.send(10)
ch.close()
}
for (x in ch) {
println("x=$x") // x=10
}
println("done") // done
}
That done is your happy ending: the consumer didn’t hang, didn’t crash, and didn’t cause drama.
Cleanup via try/finally: so consumers don’t hang
When people say “do cleanup,” it sounds like advice from the world of neat people who also do morning exercises. In practice, it’s a harsh necessity: if the producer ends for some reason (error, cancellation, early return) and the channel isn’t closed, the consumer can hang forever. And this is where it gets interesting: hanging is also an error, just without a stack trace, which makes it especially irritating.
A rule worth learning like the multiplication table: if a coroutine “owns” producing values, then closing the channel should usually be nearby, and often—in finally. This is not magic; it’s a way to guarantee the protocol completes.
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
fun main() = runBlocking {
val ch = Channel<Int>()
val producer = launch {
try {
repeat(3) { i ->
ch.send(i)
}
} finally {
ch.close()
}
}
for (x in ch) {
println("got $x") // got 0 / got 1 / got 2
}
producer.join()
}
The idea: even if something goes wrong inside repeat, finally will run, the channel will close, and the consumer will exit the loop.
And here a more mature thought appears: close() is not “resource cleanup” in the style of file.close(). It is a protocol signal: “there will be no more values.” You can close a channel without an error—simply because the work is finished.
Coroutine cancellation and CancellationException: why you must not “accidentally swallow” it
In previous days about structured concurrency, you already saw that coroutines are cancelled via Job.cancel() and are typically awaited via cancelAndJoin(). Cancellation is a normal scenario. For example, you started processing, the user changed their mind (in UI), or you have a timeout (we won’t go deep into timeouts today, but the idea is similar).
The key point: cancellation often manifests as an exception of type CancellationException. It is used as a “cancellation delivery mechanism” through suspension points (delay, receive, send, collect, etc.). So there is a fine line between “handle an error” and “break the cancellation mechanism.”
Let’s look at an example of cancelling a consumer. Note: we are not trying to “fix” cancellation; we just guarantee cleanup in finally.
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import kotlinx.coroutines.cancelAndJoin
fun main() = runBlocking {
val ch = Channel<Int>()
val consumer = launch {
try {
for (x in ch) {
println("consume $x") // consume 0 / consume 1 ...
}
} finally {
println("consumer finished") // consumer finished
}
}
repeat(5) { i -> ch.send(i) }
delay(50)
consumer.cancelAndJoin()
ch.close()
}
What matters here is that finally will run on cancellation. This is the “guaranteed point” where you can release resources, write a log, close something of your own.
The most dangerous mistake in such places is to write a broad catch (e: Throwable) and “happily” continue as if nothing happened. If you catch CancellationException and don’t rethrow it, you can make cancellation “not work,” and the system will behave strangely: someone cancelled, but it keeps living.
We won’t dive into the subtleties of how to filter exceptions correctly right now, but the practical rule is: if you catch Throwable, then cancellation almost always needs to be rethrown (for example, throw e), otherwise cancellation loses its meaning.
3. Flow: where to catch exceptions and how to end collection
Where an error surfaces and why catch is not a “shield against everything”
After channels, Flow first seems “simpler”: no close(), no protocol about “who closes,” the stream ends when the flow { ... } block ends. And that’s true. But Flow has another feature: an error usually “floats” to where you do collect. And if you picked the wrong place to handle it, you’ll look at the stack trace and think: “But I added catch! Why did it still crash?”
Let’s be careful and honest: catch in Flow catches errors that happened higher in the chain (upstream). That means: errors from flow { ... }, map, filter, onEach before catch. But if you throw an exception inside collect { ... }, catch won’t catch it, because collect is already downstream, “after the chain.”
First, an example “error in the source, caught by catch”:
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
fun main() = runBlocking {
val f = flow {
emit(1)
error("boom")
}
f.catch { e ->
println("caught: ${e.message}") // caught: boom
emit(-1)
}.collect { v ->
println("v=$v") // v=1 / v=-1
}
}
Now an example “error inside collect”: we add catch, but it won’t help, so we add an outer try/catch.
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
fun main() = runBlocking {
val f = flow { emit(1) }
try {
f.catch { println("caught upstream") }
.collect { _ ->
error("fail in collect")
}
} catch (e: Throwable) {
println("outer caught: ${e.message}") // outer caught: fail in collect
}
}
This rule is worth remembering almost like a road sign: catch does not catch errors that you throw yourself in collect. And this is logical: collect is your terminal handler, not part of the pipeline.
Cancellation of Flow and finally around collect
Now about cancelling Flow. Flow is good because it naturally “gets along” with backpressure: if processing is slow, the emitter cannot “spray” values infinitely—it will suspend at emit and other suspend points. This same mechanism means that cancellation also propagates naturally: cancel the collector’s Job—collection stops.
And this is where you appreciate finally even more, because collect often lives inside a separate coroutine. If it was cancelled, you still want to see the final line collector finished or close some of your own things.
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.collect
fun main() = runBlocking {
val f = flow {
var i = 0
while (true) {
emit(i++)
delay(50)
}
}
val job = launch {
try {
f.collect { v ->
println("v=$v") // v=0 / v=1 / v=2 ...
}
} finally {
println("collector finished") // collector finished
}
}
delay(160)
job.cancelAndJoin()
}
It’s important to notice the “coroutine + try/finally” structure. It’s exactly the same idea as with a channel; it’s just that instead of close() you have “stop collecting” and any of your completion actions.
Pay attention to the “adult nuance”: cancellation in coroutines is not “kill the thread.” It’s cooperative cancellation: the coroutine must reach a suspend point or check its state. In our example, it regularly does delay(50), so cancellation triggers quickly.
Mini example: TelemetryConsole resilient to errors and cancellation
So it doesn’t feel like we’re discussing spherical coroutines in a vacuum, let’s continue the day’s mini-app: console monitoring that prints events “as if from sensors.” Let’s call it TelemetryConsole. The idea is simple: there is an event source, there is processing, there is output. Sometimes the source breaks, sometimes the user cancels collection (in a real app, that could be leaving a page or switching modes).
Let’s make two small functions: one creates a Flow of events, and the other starts collection with robust completion.
First, the event model (intentionally very simple, without classes and architecture—we’re not doing that here):
data class TelemetryEvent(val id: Int, val message: String)
Now the source (it sometimes “crashes” so we can practice):
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
fun telemetryFlow(): Flow<TelemetryEvent> = flow {
for (i in 1..5) {
emit(TelemetryEvent(i, "ping"))
delay(30)
}
error("sensor disconnected")
}
Now startup and error handling. Note: we use catch for upstream (the source), and try/finally around collect to guarantee cleanup on cancellation and on any exit.
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
fun main() = runBlocking {
try {
telemetryFlow()
.catch { e ->
println("telemetry error: ${e.message}") // telemetry error: sensor disconnected
emit(TelemetryEvent(-1, "fallback-event"))
}
.collect { ev ->
println("event=${ev.id} ${ev.message}") // event=1 ping ...
}
} finally {
println("telemetry stopped") // telemetry stopped
}
}
In this snippet you can immediately see the “responsibility map”: the body of flow {} is responsible for generating values and can fail, catch turns a failure into understandable behavior (log + a fallback value), and the outer finally guarantees a final completion point even if collection was cancelled or something completely unexpected happened.
Cheat sheet: where to close, where to catch, where to do cleanup
Sometimes it’s useful not only to read code, but also to keep a “map” in your head. Here is a simple table-cheat-sheet (not as a law, but as a practical rule):
| Tool | How it “ends” normally | Where we usually catch errors | Where we do cleanup |
|---|---|---|---|
|
close() + consumer reads for (x in ch) to completion | In role coroutines (producer, worker) via try/catch | Usually by the channel owner: try/finally { ch.close() } |
|
the body of flow { ... } ends on its own | Upstream via catch, downstream (in collect) via try/catch around collect | try/finally around collect (and/or inside coroutine wrappers) |
And an important line worth remembering: Channel is communication between coroutines (a queue), while Flow is a description of a sequence of values that starts at collect. That’s why completion happens in different places and why the “points of truth” for errors differ.
4. Common mistakes
Mistake #1: not closing the channel because “we already sent everything anyway.”
This almost always ends with the consumer waiting for one more value that will never arrive. It’s especially tricky when you tested on a small example and “by accident” always managed to complete. A reliable style is to close the channel in finally in the coroutine that is responsible for producing values, so completion doesn’t depend on whether “we managed to reach the end.”
Mistake #2: treating a channel like a broadcast to “all listeners.”
If you start two consumers on the same Channel, each message will be received by only one of them, not both. That’s normal queue semantics, not a bug. Because of this, people sometimes think messages are “lost,” while in fact they are distributed among receivers.
Mistake #3: adding catch in Flow and being sure it will catch absolutely everything.
catch catches errors that happened before it in the chain (upstream). An error inside collect { ... } is already downstream, so you need to catch it with a normal try/catch around collect. Otherwise you’ll be surprised why “but catch is right there, and it still crashed.”
Mistake #4: writing catch (Throwable) and swallowing cancellation.
Coroutine cancellation often manifests as CancellationException. If you catch all exceptions indiscriminately and don’t rethrow cancellation, you break structured concurrency: the parent said “stop,” and the child pretended it didn’t hear. Even if you’re not ready yet for “perfectly correct” exception filtering, keep the principle in mind: cancellation is not an error; you typically don’t “fix” it—you respect it.
Mistake #5: doing cleanup “after the loop,” but not in finally.
In async code, “after the loop” might never happen: cancellation, an exception, an early return—and your cleanup didn’t run. So finally is not decoration, but a way to guarantee behavior. In coroutines this is especially important because stopping often happens at a suspend point and looks like an exception.
Mistake #6: confusing “protocol completion” with “just return from the function.”
With a Channel, exiting the producer function is not enough. If you didn’t close the channel, the consumer won’t learn that “there will be no more values.” In Flow, it’s the opposite: the body of flow {} ended—so the flow is complete, and there is no extra close() to exist or be needed. When these models get mixed up in your head, you get extra close() in the wrong places and hangs “for no clear reason.”
GO TO FULL VERSION