Skip to content

Kotlin Coroutines — Asynchronous Programming Guide

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about Kotlin Coroutines. We cover key concepts, practical examples, and best practices to help you master this topic.

Kotlin coroutines provide a structured concurrency framework with suspend functions, coroutine builders, dispatchers for thread management, and cancellation propagation for writing asynchronous code that reads like sequential code.

What You'll Learn

  • Understand suspend functions and how coroutines work
  • Use launch and async builders for fire-and-forget and async tasks
  • Switch between dispatchers for main, IO, and default threads
  • Handle structured concurrency with coroutineScope
  • Cancel coroutines and handle cancellation
  • Use withContext for thread-safe operations
  • Implement Exception Handling and supervision

Why It Matters

Asynchronous programming is essential for modern apps. Network calls, database operations, and file I/O must not block the main thread. Coroutines are Kotlin's answer to this problem. They are more lightweight than threads, easier to use than callbacks, and more structured than RxJava. Coroutines integrate directly with Android's lifecycle, Room, Retrofit, and Compose.

Real-World Use

DodaTech uses coroutines for all async operations. The malware scanner runs CPU-intensive checks on Dispatchers.Default, network signature updates use Dispatchers.IO, and UI updates run on Dispatchers.Main. Structured concurrency ensures that when a scan is canceled, all child coroutines are canceled automatically.

Learning Path

flowchart LR
  A[Navigation] --> B[Coroutines\nYou are here]
  B --> C[Flow]
  style B fill:#f90,color:#fff

Your First Coroutine

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Start: ${Thread.currentThread().name}")
    
    launch {
        delay(1000)
        println("World: ${Thread.currentThread().name}")
    }
    
    println("Hello")
}

Output: Hello prints immediately, then after 1 second World prints. The program waits for the launch coroutine to finish before exiting.

Suspend Functions

A suspend function can be paused and resumed without blocking a thread.

suspend fun fetchUserData(userId: String): String {
    println("Fetching data for user $userId on ${Thread.currentThread().name}")
    delay(2000)  // Simulate network call
    return "User data for $userId"
}

suspend fun processUserData(data: String): String {
    println("Processing on ${Thread.currentThread().name}")
    delay(1000)
    return "Processed: $data"
}

fun main() = runBlocking {
    val start = System.currentTimeMillis()
    
    val data = fetchUserData("42")
    val result = processUserData(data)
    
    println("Result: $result")
    println("Time: ${System.currentTimeMillis() - start}ms")
}

Output: The program fetches and processes user data sequentially, taking approximately 3 seconds. The main thread is not blocked during delays.

Coroutine Builders

Builder Purpose Returns
launch Fire-and-forget Job
async Await a result Deferred
runBlocking Bridge blocking and suspend code T
fun main() = runBlocking {
    // launch: fire and forget
    val job = launch {
        delay(1000)
        println("Task from launch")
    }
    
    // async: get a result later
    val deferred = async {
        delay(500)
        "Result from async"
    }
    
    println("Waiting...")
    println(deferred.await())  // Waits for the async result
    job.join()  // Waits for the launch to complete
}

Output: The async result ("Result from async") prints after 500ms. The launch result prints after 1000ms.

Dispatchers

Dispatchers control which thread pool the coroutine runs on.

suspend fun performOperations() {
    // Main thread (UI operations)
    withContext(Dispatchers.Main) {
        // Update UI
    }
    
    // IO operations (network, database, file)
    withContext(Dispatchers.IO) {
        // Make network call
        // Query database
        delay(1000)
        println("IO work on ${Thread.currentThread().name}")
    }
    
    // CPU-intensive work
    withContext(Dispatchers.Default) {
        // Heavy computation
        var result = 0L
        for (i in 1..10_000_000) result += i
        println("Default work on ${Thread.currentThread().name}")
    }
    
    // Unconfined (inherits from caller, useful for testing)
    withContext(Dispatchers.Unconfined) {
        println("Unconfined on ${Thread.currentThread().name}")
    }
}

fun main() = runBlocking {
    performOperations()
}

Output: Each operation runs on the appropriate thread pool. Dispatchers.Default uses a thread pool sized to the number of CPU cores.

Structured Concurrency

When a parent coroutine is canceled, all its children are canceled.

fun main() = runBlocking {
    val parentJob = launch {
        launch {
            repeat(10) { i ->
                delay(200)
                println("Child 1: $i")
            }
        }
        
        launch {
            repeat(10) { i ->
                delay(300)
                println("Child 2: $i")
            }
        }
    }
    
    delay(700)
    println("Cancelling parent...")
    parentJob.cancel()
    parentJob.join()
    println("Parent cancelled")
}

Output: Both children print a few iterations before the parent is canceled. After cancellation, no more output from children.

Exception Handling

Coroutines handle exceptions differently depending on the builder.

fun main() = runBlocking {
    // launch: exception propagates to the parent
    val job = launch {
        try {
            delay(100)
            throw RuntimeException("Error in launch")
        } catch (e: Exception) {
            println("Caught: ${e.message}")
        }
    }
    job.join()
    
    // async: exception is captured in Deferred
    val deferred = async {
        delay(100)
        throw RuntimeException("Error in async")
    }
    
    try {
        deferred.await()
    } catch (e: Exception) {
        println("Caught async: ${e.message}")
    }
    
    // Global exception handler
    val handler = CoroutineExceptionHandler { _, exception ->
        println("Global handler: ${exception.message}")
    }
    
    val handledJob = launch(handler) {
        throw RuntimeException("Unhandled error")
    }
    handledJob.join()
}

Output: Exceptions in launch propagate to the parent. Exceptions in async are captured and thrown on await().

SupervisorJob

SupervisorJob prevents child failures from canceling siblings.

fun main() = runBlocking {
    val supervisor = SupervisorJob()
    val scope = CoroutineScope(supervisor + Dispatchers.Default)
    
    val child1 = scope.launch {
        repeat(5) { i ->
            delay(200)
            println("Child 1: $i")
        }
    }
    
    val child2 = scope.launch {
        delay(300)
        throw RuntimeException("Child 2 failed")
    }
    
    delay(2000)
    println("Child 1 active: ${child1.isActive}")
    supervisor.complete()
}

Output: Child 1 continues running even though Child 2 failed. Without SupervisorJob, Child 1 would be cancelled.

withContext for Returning Results

withContext switches dispatchers and returns a result.

suspend fun loadUserData(userId: String): String = withContext(Dispatchers.IO) {
    delay(1500)
    "User data for $userId loaded on ${Thread.currentThread().name}"
}

suspend fun loadUserPreferences(userId: String): String = withContext(Dispatchers.IO) {
    delay(1000)
    "Preferences for $userId"
}

fun main() = runBlocking {
    val start = System.currentTimeMillis()
    
    // Sequential
    val data = loadUserData("42")
    val prefs = loadUserPreferences("42")
    println("Sequential: ${System.currentTimeMillis() - start}ms")
    
    // Parallel with async
    val start2 = System.currentTimeMillis()
    val dataDeferred = async { loadUserData("42") }
    val prefsDeferred = async { loadUserPreferences("42") }
    val data2 = dataDeferred.await()
    val prefs2 = prefsDeferred.await()
    println("Parallel: ${System.currentTimeMillis() - start2}ms")
}

Output: Sequential takes ~2.5s. Parallel takes ~1.5s (max of the two delays).

Common Mistakes

  1. Not using Dispatchers.Main for UI updates: Modifying UI from a background thread crashes the app. Use withContext(Dispatchers.Main) or ensure UI updates happen on the main thread.

  2. Using GlobalScope: GlobalScope creates top-level coroutines not tied to any lifecycle. They can leak and run indefinitely. Always use a structured scope.

  3. Cancelling a Job without joining: cancel() returns immediately. join() waits for cancellation to complete. Use cancelAndJoin() to wait for cleanup.

  4. Throwing exceptions in async without catching await: Async exceptions are silent until await() is called. Always wrap await() in try-catch or handle through the deferred.

  5. Forgetting that delay is a suspend function: delay() only works in coroutines and suspend functions. In regular code, use Thread.sleep() (but avoid it).

  6. Not structuring parallel work: Use async/await for independent parallel tasks. Sequential code using multiple withContext blocks wastes time.

Practice Questions

  1. What is a suspend function?

Answer: A suspend function is a function that can be paused and resumed later without blocking a thread. It can only be called from a coroutine or another suspend function.

  1. What is the difference between launch and async?

Answer: launch returns a Job and is used for fire-and-forget operations. async returns a Deferred that produces a result, retrieved via await().

  1. What dispatchers does Kotlin provide?

Answer: Dispatchers.Main (UI thread), Dispatchers.IO (network/database), Dispatchers.Default (CPU-intensive), and Dispatchers.Unconfined (first suspension point's thread).

  1. How does structured concurrency work?

Answer: Every coroutine has a parent. If the parent is canceled, all children are canceled. If a child fails, the parent and siblings are canceled (unless using SupervisorJob).

  1. Challenge: Implement a timeout mechanism for a coroutine that calls an external API. If the API does not respond within 5 seconds, cancel the request and return a default value.

Answer:

suspend fun <T> withTimeoutFallback(
    timeoutMs: Long,
    default: T,
    block: suspend () -> T
): T {
    return try {
        withTimeout(timeoutMs) {
            block()
        }
    } catch (e: TimeoutCancellationException) {
        println("Operation timed out after ${timeoutMs}ms, using default")
        default
    }
}

suspend fun fetchApiData(): String {
    delay(3000)  // Simulate slow API
    return "API response"
}

fun main() = runBlocking {
    val result = withTimeoutFallback(2000, "Default data") {
        fetchApiData()
    }
    println("Result: $result")  // Output: Operation timed out after 2000ms, using default / Result: Default data
}

Mini Project

Build a file downloader with coroutines. Requirements:

  • Download multiple files in parallel using async
  • Track progress using a mutable state
  • Support cancellation (user can cancel all downloads)
  • Use Dispatchers.IO for downloading and Dispatchers.Main for UI
  • Handle errors: each download failure should not affect others (SupervisorJob)
  • Show total download time

This project applies structured concurrency, dispatchers, exception handling, and cancellation in a practical scenario.

FAQ

How are coroutines different from threads?

Coroutines are much lighter than threads. Thousands of coroutines can run on a single thread. They are suspendable instead of blocking, allowing more efficient resource usage.

What is the difference between cancel() and cancelAndJoin()?

cancel() marks the job as cancelled and returns immediately. cancelAndJoin() cancels and waits for the job to complete cancellation, running any cleanup code.

Can I use coroutines without Kotlinx libraries?

The kotlinx.coroutines library is the standard implementation. Without it, you only have the language-level suspend keyword but no dispatchers, builders, or Job support.

What is coroutineScope vs supervisorScope?

coroutineScope cancels all children if any child fails. supervisorScope isolates failures so one child's failure does not cancel siblings.

How do I convert a callback-based API to coroutines?

Use suspendCoroutine or suspendCancellableCoroutine to bridge callback APIs into suspend functions.

What's Next

After mastering coroutines, learn Flow for reactive streams. You can also explore KMP basics for cross-platform development.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro