Kotlin Crash Course
Chapter 6

Coroutines

Async programming without callback hell. Learn suspend functions, launch, async/await, and Flow for reactive streams.

By the end of this chapter, you will:

  • Understand why coroutines exist and how they differ from threads
  • Launch coroutines with launch and async
  • Write and call suspend functions
  • Handle cancellation and exceptions
  • Understand the basics of Flow

The Problem with Threads

Traditional threading is expensive. Each thread consumes ~1MB of memory, and context switching is slow. You cannot launch millions of threads.

import kotlin.concurrent.thread

// Bad: Creating a thread for every network request
fun fetchUserBad(userId: String) {
    thread {   // Expensive! Each thread = ~1MB memory
        val user = makeNetworkRequest(userId)
        println(user)
    }
}

// Good: Coroutine — lightweight, managed by Kotlin's runtime
suspend fun fetchUserGood(userId: String) {
    val user = makeNetworkRequest(userId)  // suspends, doesn't block
    println(user)
}
Key insight: Coroutines are lightweight threads managed by Kotlin's runtime, not the operating system. You can launch millions of them on a single thread. They suspend instead of block — freeing the underlying thread to do other work.
Real-world impact: A backend service handling 10,000 concurrent WebSocket connections would need gigabytes of RAM with threads. With coroutines, it fits in a single JVM with tens of megabytes.

Your First Coroutine

To launch a coroutine, you need a CoroutineScope. For learning, use runBlocking. In real apps, use structured scopes tied to lifecycle (ViewModelScope, LifecycleScope).

GlobalScope is discouraged in production: GlobalScope lives for the entire application lifetime and is not bound to any lifecycle. Prefer structured scopes like lifecycleScope, viewModelScope, or create your own CoroutineScope tied to a specific component.
import kotlinx.coroutines.*

fun main() = runBlocking {   // Blocks main thread until coroutines complete
    launch {                 // Launches a new coroutine
        delay(1000L)         // Non-blocking delay (like setTimeout)
        println("World!")
    }
    println("Hello,")          // Prints immediately
    // Output: Hello, (wait 1s) World!
}

launch vs async

runBlocking {
    // launch: fire and forget, returns Job
    val job = launch {
        delay(100)
        println("Done")
    }
    job.join()   // Wait for completion

    // async: returns a result, returns Deferred<T>
    val deferred = async {
        delay(100)
        "Result"
    }
    val result = deferred.await()   // "Result"
}

Compare with JS / Python

JavaScript
// Promise-based
async function main() {
    setTimeout(() => console.log("World!"), 1000);
    console.log("Hello,");
}

// Or with Promise
const result = await fetch(url);
const data = await result.json();
Python
import asyncio

async def main():
    await asyncio.sleep(1)
    print("World!")

asyncio.run(main())

Kotlin difference: Coroutines are more structured than JS Promises. launch/async run in a scope, and child coroutines are cancelled when the parent is. This prevents leaks. JS and Python have no built-in structured concurrency.

Dispatchers

Dispatchers determine which thread or thread pool a coroutine runs on. Choosing the right dispatcher prevents blocking the UI thread and keeps your app responsive.

// Dispatchers.Main    — Android/UI thread (updates views)
// Dispatchers.IO      — Network and disk I/O (thread pool)
// Dispatchers.Default — CPU-intensive work (thread pool)
// Dispatchers.Unconfined — Starts in caller context, jumps around

suspend fun loadData() {
    withContext(Dispatchers.IO) {
        val data = fetchFromNetwork()   // Safe to block with IO
    }
}
Rule of thumb: UI updates on Main, network/files on IO, algorithms/JSON parsing on Default. Never call Dispatchers.IO functions directly from the UI thread without withContext.

Switching Contexts with withContext

withContext lets you move a coroutine to a different dispatcher, do work, and return the result — all without callbacks. Execution is sequential but happens on different threads.

suspend fun updateDashboard() {
    val data = withContext(Dispatchers.IO) {
        fetchFromNetwork()          // runs on IO thread pool
    }

    val processed = withContext(Dispatchers.Default) {
        parseAndTransform(data)     // CPU work on Default pool
    }

    withContext(Dispatchers.Main) {
        displayResult(processed)    // back to UI thread
    }
}
Use case: Fetch JSON from an API on IO, parse it on Default, then render it on Main. Each step is readable and sequential, yet runs on the optimal thread.

SupervisorScope

In a regular coroutineScope, if one child fails, all siblings are cancelled. supervisorScope isolates failures so that one crashing child doesn't bring down the others.

supervisorScope {
    val job1 = launch {
        mightFail()   // throws exception
    }

    val job2 = launch {
        alwaysWorks() // continues running even if job1 fails
    }

    // Optionally handle exceptions per child
    job1.invokeOnCompletion { exception ->
        exception?.let { println("job1 failed: $it") }
    }
}
Use case: Independent work items in a batch job. If one upload fails, the others should continue. Also used in Android SupervisorJob to keep a ViewModel alive when one use case crashes.

Async Exception Handling

Exceptions in async are deferred until you call await(). Handle them with try/catch or a CoroutineExceptionHandler for uncaught exceptions.

// Exception is deferred until await()
val deferred = async {
    fetchDataThatMightFail()
}

try {
    val result = deferred.await()
} catch (e: Exception) {
    println("Handled: $e")
}

// Global uncaught exception handler
val handler = CoroutineExceptionHandler { _, exception ->
    println("Uncaught: $exception")
}

val scope = CoroutineScope(Dispatchers.Main + handler)
scope.launch {
    throw RuntimeException("Oops")
}
Rule of thumb: Wrap await() in try/catch when you expect failures. Use CoroutineExceptionHandler as a safety net for unexpected crashes in top-level scopes.

Channels

Channels provide a way for coroutines to communicate with each other — like Go channels or Python queues. They're perfect for producer-consumer patterns where one coroutine produces data and another consumes it.

import kotlinx.coroutines.channels.Channel

val channel = Channel<Int>()

// Producer
launch {
    for (x in 1..5) channel.send(x * x)
    channel.close()
}

// Consumer
launch {
    for (value in channel) {
        println(value)   // 1, 4, 9, 16, 25
    }
}
Use case: Background workers sending progress updates to the UI, parallel pipelines where one coroutine produces data and another transforms it, and backpressure-safe queues between modules.

Suspend Functions

A suspend function can pause execution without blocking the thread. It can only be called from another suspend function or a coroutine.

// Mark a function as suspend
suspend fun fetchUser(id: String): User {
    delay(1000)   // Suspend for 1 second, but don't block the thread
    return User(id, "Alex")
}

suspend fun fetchOrders(userId: String): List<Order> {
    delay(500)
    return listOf(Order("1"), Order("2"))
}

// Sequential execution (default)
suspend fun loadDashboard(userId: String) {
    val user = fetchUser(userId)        // Wait for user
    val orders = fetchOrders(user.id)   // Then fetch orders
    println("Loaded $user with $orders")
}

// Concurrent execution with async
suspend fun loadDashboardFast(userId: String) {
    coroutineScope {   // Creates a scope for structured concurrency
        val userDeferred = async { fetchUser(userId) }
        val ordersDeferred = async { fetchOrders(userId) }

        val user = userDeferred.await()       // Both started concurrently!
        val orders = ordersDeferred.await()   // Wait for both
        println("Loaded $user with $orders")
    }
}
Rule:
• Suspend functions look synchronous but execute asynchronously
• Sequential by default, concurrent when you explicitly use async
• Differs from JavaScript where async functions always return Promises and everything runs concurrently unless you await
Practical pattern: In an Android ViewModel, use sequential suspend calls for dependent operations (fetch user → fetch orders). Use async only when requests are independent (fetch profile + fetch notifications simultaneously).

Cancellation

Coroutines are cooperative. They check for cancellation at suspension points. Use isActive for long-running CPU work.

val job = launch {
    repeat(1000) { i ->
        println("Working $i...")
        delay(500)
    }
}

delay(1300)    // Let it print a few times
job.cancel()   // Cancel the coroutine
job.join()     // Wait for cancellation to complete

// For CPU-intensive work, check isActive
launch {
    while (isActive) {
        doSomeWork()
    }
}
Use case: Cancel a network request when the user leaves a screen. In Android, viewModelScope automatically cancels all children when the ViewModel is cleared — preventing leaks and wasted bandwidth.

Flow (Reactive Streams)

Flow is a cold stream of values — like RxJava or JS Observables, but built into coroutines.

import kotlinx.coroutines.flow.*

// A Flow emits multiple values over time
fun userUpdates(): Flow<User> = flow {
    emit(User("1", "Alex"))
    delay(1000)
    emit(User("1", "Alex Updated"))
}

// Collecting a Flow
runBlocking {
    userUpdates().collect { user ->
        println("Received: $user")
    }
}

// Flow operators (like collections!)
userUpdates()
    .filter { it.name.startsWith("A") }
    .map { it.name.uppercase() }
    .collect { println(it) }
Flow vs LiveData: In modern Android, Flow replaces LiveData for reactive data. Flow is lifecycle-aware when collected with repeatOnLifecycle, works with any coroutine scope, and supports backpressure and complex transformations.
Real-world Flow: Expose database queries as Flow to automatically emit new values when data changes. Combine multiple flows for form validation (e.g., email.combine(password) { ... }). Use StateFlow for UI state that survives configuration changes.
// SupervisorJob — child failures don't cancel siblings
val supervisor = SupervisorJob()
val scope = CoroutineScope(supervisor + Dispatchers.Default)

scope.launch {
    println("Task A running")
}
scope.launch {
    throw RuntimeException("Task B failed")  // Task A keeps running!
}

// withTimeoutOrNull — return null on timeout
val result = withTimeoutOrNull(1000L) {
    fetchSlowNetworkData()  // if this takes > 1s, returns null
}

Error Handling with Result

Kotlin's Result<T> type encapsulates success or failure without throwing exceptions. Use runCatching { } to execute code that might throw, then handle both cases functionally.

// runCatching returns Result
val result: Result<User> = runCatching {
    fetchUserFromNetwork("123")  // might throw IOException
}

// Handle with fold
result.fold(
    onSuccess = { user -> displayUser(user) },
    onFailure = { error -> showError(error.message) }
)

// Or use functional chaining
val name = result
    .map { it.name.uppercase() }           // transform on success
    .recover { "Unknown User" }             // provide fallback on failure
    .getOrDefault("Guest")                  // unwrap with default
// In coroutines — use suspendCatching (or runCatching works too)
suspend fun loadData(): Result<Data> = runCatching {
    val response = api.fetchData()
    response.validate()                    // might throw
    response.toData()
}

// Collecting Result from async
val deferred = async { loadData() }
deferred.await()                           // Result
When to use Result: Use Result for expected failures (network errors, validation failures) where the caller should decide what to do. Use exceptions for programming errors (null dereference, illegal state) that should never happen in production.

Try It Yourself

Write a suspend function that fetches two URLs concurrently and returns the combined string length of both responses.

suspend fun fetchBoth(url1: String, url2: String): Int {
    // Your code here:


}

// Should work like:
// val total = fetchBoth("https://api1.com", "https://api2.com")
// println(total)  // sum of both response lengths
Stuck? Show hint
Use coroutineScope { }, launch two async blocks, await() both, then sum their .length.

Concept Check

What happens if you call delay(1000) inside a regular (non-suspend) function?

It blocks the thread for 1 second
Compile error — delay is a suspend function
Nothing — it works like Thread.sleep