In the previous tutorial, you learned the basics of coroutines: suspend functions, launch, async, and structured concurrency. Now let’s dive deeper into error handling, supervisor jobs, and channels.
In this tutorial, you will learn:
CoroutineExceptionHandlerSupervisorJobandsupervisorScope- Channels (send, receive, close)
- Channel types (buffered, conflated, unlimited)
produceandconsumeEach- Fan-out and fan-in patterns
selectexpressionMutexfor shared mutable statewithTimeoutandwithTimeoutOrNull- Practical patterns
Error Handling in Coroutines
Error handling in coroutines is different from regular code. When a coroutine throws an exception, the behavior depends on whether you use launch or async.
launch vs async Exceptions
With launch, exceptions propagate immediately to the parent scope. With async, exceptions are stored in the Deferred and thrown when you call .await().
coroutineScope {
// launch — exception propagates immediately
launch {
throw RuntimeException("launch error")
// Parent scope is cancelled
}
}
// async — exception is delivered on await
supervisorScope {
val deferred = async {
throw RuntimeException("async error")
}
try {
deferred.await()
} catch (e: RuntimeException) {
println("Caught: ${e.message}")
}
}
CoroutineExceptionHandler
CoroutineExceptionHandler catches uncaught exceptions in coroutines. It works only with launch (not async). You must install it on a root coroutine scope or on the scope itself.
val handler = CoroutineExceptionHandler { _, exception ->
println("Caught: ${exception.message}")
}
val scope = CoroutineScope(SupervisorJob() + handler)
scope.launch {
throw RuntimeException("Something went wrong")
}
// Output: Caught: Something went wrong
Think of CoroutineExceptionHandler as a last resort. It catches exceptions that would otherwise be lost. For normal error handling, use try-catch inside the coroutine.
When to Use try-catch vs Handler
| Approach | Use Case |
|---|---|
try-catch inside coroutine | Expected errors you can recover from |
CoroutineExceptionHandler | Last-resort logging, crash reporting |
supervisorScope | Isolate independent tasks |
SupervisorJob
With a regular Job, if one child fails, all children are cancelled. This is the default behavior of structured concurrency.
With SupervisorJob, if one child fails, other children keep running. Use SupervisorJob when children are independent tasks.
val handler = CoroutineExceptionHandler { _, exception ->
println("Caught: ${exception.message}")
}
val supervisor = CoroutineScope(SupervisorJob() + handler)
supervisor.launch {
println("Child 1 started")
delay(100)
throw RuntimeException("Child 1 failed!")
}
supervisor.launch {
println("Child 2 started")
delay(200)
println("Child 2 completed") // This still runs!
}
Output:
Child 1 started
Child 2 started
Caught: Child 1 failed!
Child 2 completed
Without SupervisorJob, Child 2 would be cancelled when Child 1 fails. With SupervisorJob, Child 2 continues running.
When to Use SupervisorJob
Use SupervisorJob when:
- Children are independent tasks (loading different sections of a screen)
- One failure should not affect others
- You want to handle each failure separately
Use regular Job when:
- Children depend on each other
- If one fails, the others are useless
- You want all-or-nothing behavior
supervisorScope
supervisorScope is the suspend function version of SupervisorJob. It creates a scope where child failures don’t cancel other children.
suspend fun loadDashboard(): Dashboard {
return supervisorScope {
val news = async { fetchNews() } // May fail
val weather = async { fetchWeather() } // May fail
val stocks = async { fetchStocks() } // May fail
Dashboard(
news = runCatching { news.await() }.getOrDefault(emptyList()),
weather = runCatching { weather.await() }.getOrNull(),
stocks = runCatching { stocks.await() }.getOrDefault(emptyList())
)
}
}
Each section loads independently. If one fails, the others still show data. This is a common pattern in mobile apps.
Channels
A Channel is a communication tool between coroutines. Think of it as a queue: one coroutine sends values, another receives them.
Channels are hot — they exist even without collectors (unlike Flow, which is cold).
Basic Channel
coroutineScope {
val channel = Channel<Int>()
// Producer
launch {
for (i in 1..5) {
channel.send(i)
println("Sent: $i")
}
channel.close() // Close when done
}
// Consumer
for (value in channel) {
println("Received: $value")
}
}
Output:
Sent: 1
Received: 1
Sent: 2
Received: 2
Sent: 3
Received: 3
Sent: 4
Received: 4
Sent: 5
Received: 5
send() suspends if the channel is full. receive() suspends if the channel is empty. Always close() the channel when done.
Channel Types
Kotlin provides different channel types that control buffering:
| Type | Buffer | Behavior |
|---|---|---|
Channel.RENDEZVOUS (0) | None | send suspends until receive |
Channel.BUFFERED (64) | Default | send suspends when buffer is full |
Channel.UNLIMITED | Unlimited | send never suspends |
Channel.CONFLATED | 1 (latest) | Keeps only the latest value |
// Buffered channel — can hold 3 values before suspending
val buffered = Channel<Int>(3)
// Conflated channel — keeps only the latest value
val conflated = Channel<Int>(Channel.CONFLATED)
conflated.send(1)
conflated.send(2)
conflated.send(3)
println(conflated.receive()) // 3 (only the latest)
Choosing a Channel Type
- RENDEZVOUS: Use when producer and consumer should work in sync
- BUFFERED: Use for general-purpose communication
- UNLIMITED: Use when you don’t want the producer to wait (careful with memory)
- CONFLATED: Use when only the latest value matters (like UI state)
produce and consumeEach
produce is a coroutine builder that creates a ReceiveChannel. It closes the channel automatically when the producer finishes.
fun CoroutineScope.produceNumbers(start: Int, count: Int): ReceiveChannel<Int> {
return produce {
for (i in start until start + count) {
send(i)
delay(50)
}
}
}
consumeEach iterates over a channel and handles closing:
coroutineScope {
val numbers = produceNumbers(1, 5)
numbers.consumeEach { println(it) }
}
Channel Pipelines
You can chain channels together to create processing pipelines:
fun CoroutineScope.produceNumbers(start: Int, count: Int): ReceiveChannel<Int> =
produce {
for (i in start until start + count) {
send(i)
}
}
fun CoroutineScope.squareNumbers(numbers: ReceiveChannel<Int>): ReceiveChannel<Int> =
produce {
for (n in numbers) {
send(n * n)
}
}
// Usage
coroutineScope {
val numbers = produceNumbers(1, 5)
val squares = squareNumbers(numbers)
squares.consumeEach { println(it) } // 1, 4, 9, 16, 25
}
Each stage of the pipeline runs as a separate coroutine. Data flows through the pipeline one element at a time.
Fan-out and Fan-in
Fan-out
Fan-out means multiple coroutines read from the same channel. This is useful for distributing work among workers.
coroutineScope {
val channel = produce {
for (i in 1..10) { send(i) }
}
// Multiple workers reading from same channel
repeat(3) { workerId ->
launch {
for (value in channel) {
println("Worker $workerId processed: $value")
delay(50)
}
}
}
}
Each value is received by only one worker. The channel distributes work automatically.
Fan-in
Fan-in means multiple coroutines write to the same channel. This is useful for merging multiple data sources.
coroutineScope {
val channel = Channel<String>()
// Multiple producers
launch { repeat(3) { channel.send("A$it"); delay(50) } }
launch { repeat(3) { channel.send("B$it"); delay(70) } }
// Single consumer
repeat(6) {
println("Received: ${channel.receive()}")
}
channel.close()
}
select Expression
The select expression lets you wait on multiple suspending operations at once. It picks the first one that is ready.
coroutineScope {
val fast = produce {
delay(50)
send("fast result")
}
val slow = produce {
delay(200)
send("slow result")
}
val result = select<String> {
fast.onReceive { it }
slow.onReceive { it }
}
println("Winner: $result") // "fast result"
coroutineContext.cancelChildren()
}
select Use Cases
- Race: Pick the fastest response from multiple servers
- Priority: Check a high-priority channel first
- Timeout: Combine with a timeout channel
// Race between cache and network
val result = select<String> {
cacheChannel.onReceive { "cache: $it" }
networkChannel.onReceive { "network: $it" }
}
withTimeout
withTimeout cancels the coroutine if it takes too long. It throws TimeoutCancellationException.
try {
withTimeout(200) {
delay(500) // Takes too long
println("Done")
}
} catch (e: TimeoutCancellationException) {
println("Timed out!")
}
withTimeoutOrNull returns null instead of throwing:
val fast = withTimeoutOrNull(200) {
delay(100)
"fast"
}
println(fast) // "fast"
val slow = withTimeoutOrNull(200) {
delay(500)
"slow"
}
println(slow) // null
Mutex — Safe Shared Mutable State
When multiple coroutines access shared mutable state, you can get race conditions. Mutex provides mutual exclusion, ensuring only one coroutine accesses the critical section at a time.
val mutex = Mutex()
var counter = 0
coroutineScope {
repeat(1000) {
launch {
mutex.withLock {
counter++
}
}
}
}
println(counter) // 1000 — always correct
Without the mutex, the counter could be less than 1000 because multiple coroutines might read and write at the same time.
Mutex vs synchronized
| Feature | Mutex | synchronized |
|---|---|---|
| Blocking | No (suspends) | Yes (blocks thread) |
| Coroutine-friendly | Yes | No |
| Use case | Coroutines | Threads |
Always use Mutex instead of synchronized in coroutine code. synchronized blocks the thread, while Mutex suspends the coroutine and lets the thread do other work.
Mutex with Collections
val mutex = Mutex()
val items = mutableListOf<String>()
coroutineScope {
val jobs = List(100) { i ->
launch {
val result = processItem(i) // suspend function
mutex.withLock {
items.add(result)
}
}
}
jobs.forEach { it.join() }
}
println("Processed ${items.size} items") // 100
Keep the critical section (code inside withLock) as short as possible. Do heavy work outside the lock.
Practical Example: Worker Pool
Process items using a pool of workers. This is a common pattern for limiting concurrency.
suspend fun workerPool(items: List<String>, workerCount: Int): List<String> {
val mutex = Mutex()
val results = mutableListOf<String>()
coroutineScope {
val channel = Channel<String>()
// Launch workers
val workers = List(workerCount) {
launch {
for (item in channel) {
val processed = item.uppercase()
mutex.withLock { results.add(processed) }
}
}
}
// Send items to channel
for (item in items) {
channel.send(item)
}
channel.close()
// Wait for all workers
workers.forEach { it.join() }
}
return results.sorted()
}
// Usage
val result = workerPool(
items = listOf("hello", "world", "kotlin", "coroutines"),
workerCount = 2
)
println(result) // [COROUTINES, HELLO, KOTLIN, WORLD]
The channel distributes items among workers. Each worker processes items from the channel until it is closed.
Practical Example: Race Operations
Return the first result from multiple concurrent operations:
suspend fun raceOperations(
vararg operations: suspend () -> String
): String {
return coroutineScope {
val channels = operations.map { op ->
produce { send(op()) }
}
val result = select<String> {
channels.forEach { ch ->
ch.onReceive { it }
}
}
coroutineContext.cancelChildren()
result
}
}
// Usage — returns whichever finishes first
val result = raceOperations(
{ delay(100); "server1" },
{ delay(50); "server2" }, // Wins!
{ delay(200); "server3" }
)
println(result) // "server2"
Practical Example: Ticker
Emit values at regular intervals using a channel:
fun CoroutineScope.ticker(intervalMs: Long, count: Int): ReceiveChannel<Int> {
return produce {
repeat(count) { i ->
delay(intervalMs)
send(i)
}
}
}
// Usage
coroutineScope {
val tick = ticker(1000, 5)
repeat(5) {
val value = tick.receive()
println("Tick $value at ${System.currentTimeMillis()}")
}
}
Channels vs Flow
Channels and Flow are both ways to send multiple values. But they are different:
| Feature | Channel | Flow |
|---|---|---|
| Hot/Cold | Hot (runs immediately) | Cold (runs on collect) |
| Consumers | Values split between consumers | Each collector gets all values |
| Buffering | Configurable | Depends on operator |
| Backpressure | Built-in (suspend on full) | Built-in (suspend on slow) |
| Lifecycle | Must close manually | Automatic |
| Use case | Communication | Data streams |
Use Channels when:
- Multiple coroutines need to communicate
- You want fan-out (distribute work)
- You need a queue between producer and consumer
Use Flow when:
- You have a stream of data to transform
- Each collector should get all values
- You want operators like map, filter, combine
In most cases, Flow is the better choice. Use Channels when you need communication between coroutines.
Common Mistakes
Mistake 1: Not Using SupervisorJob for Independent Tasks
// BAD — if one task fails, all are cancelled
coroutineScope {
launch { loadNews() }
launch { loadWeather() }
launch { loadStocks() }
}
// GOOD — tasks are independent
supervisorScope {
launch { loadNews() }
launch { loadWeather() }
launch { loadStocks() }
}
Mistake 2: Forgetting to Close Channels
// BAD — consumer hangs forever
val channel = Channel<Int>()
launch {
for (i in 1..5) channel.send(i)
// Forgot to close!
}
for (value in channel) { /* hangs after 5 values */ }
// GOOD — use produce (auto-closes) or close manually
val channel = produce {
for (i in 1..5) send(i)
}
Mistake 3: Not Handling Exceptions in supervisorScope
// BAD — exception is lost
supervisorScope {
async { riskyOperation() }
}
// GOOD — handle the exception
supervisorScope {
val result = async { riskyOperation() }
try {
result.await()
} catch (e: Exception) {
println("Failed: ${e.message}")
}
}
Summary
| Concept | Description |
|---|---|
CoroutineExceptionHandler | Last-resort exception handler for launch |
SupervisorJob | Child failures don’t cancel siblings |
supervisorScope | Suspend function version of SupervisorJob |
Channel | Queue for coroutine communication |
produce | Creates a ReceiveChannel from a coroutine |
consumeEach | Iterates over a channel |
select | Waits on multiple operations, picks first ready |
withTimeout | Cancels if too slow (throws) |
withTimeoutOrNull | Cancels if too slow (returns null) |
Mutex | Mutual exclusion for shared mutable state |
| Fan-out | Multiple workers read from one channel |
| Fan-in | Multiple producers write to one channel |
Source Code
You can find the source code for this tutorial on GitHub: tutorial-18-coroutines-deep
What’s Next?
In the next tutorial, you will learn about Kotlin Flow — reactive streams that work with coroutines.