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:

  • CoroutineExceptionHandler
  • SupervisorJob and supervisorScope
  • Channels (send, receive, close)
  • Channel types (buffered, conflated, unlimited)
  • produce and consumeEach
  • Fan-out and fan-in patterns
  • select expression
  • Mutex for shared mutable state
  • withTimeout and withTimeoutOrNull
  • 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

ApproachUse Case
try-catch inside coroutineExpected errors you can recover from
CoroutineExceptionHandlerLast-resort logging, crash reporting
supervisorScopeIsolate 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:

TypeBufferBehavior
Channel.RENDEZVOUS (0)Nonesend suspends until receive
Channel.BUFFERED (64)Defaultsend suspends when buffer is full
Channel.UNLIMITEDUnlimitedsend never suspends
Channel.CONFLATED1 (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

FeatureMutexsynchronized
BlockingNo (suspends)Yes (blocks thread)
Coroutine-friendlyYesNo
Use caseCoroutinesThreads

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:

FeatureChannelFlow
Hot/ColdHot (runs immediately)Cold (runs on collect)
ConsumersValues split between consumersEach collector gets all values
BufferingConfigurableDepends on operator
BackpressureBuilt-in (suspend on full)Built-in (suspend on slow)
LifecycleMust close manuallyAutomatic
Use caseCommunicationData 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

ConceptDescription
CoroutineExceptionHandlerLast-resort exception handler for launch
SupervisorJobChild failures don’t cancel siblings
supervisorScopeSuspend function version of SupervisorJob
ChannelQueue for coroutine communication
produceCreates a ReceiveChannel from a coroutine
consumeEachIterates over a channel
selectWaits on multiple operations, picks first ready
withTimeoutCancels if too slow (throws)
withTimeoutOrNullCancels if too slow (returns null)
MutexMutual exclusion for shared mutable state
Fan-outMultiple workers read from one channel
Fan-inMultiple 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.