Kotlin Coroutines Deep Dive
Introduction
Kotlin coroutines have become the de facto standard for handling asynchronous work on Android. They give you the readability of sequential code without blocking threads, and they integrate cleanly with modern Android APIs like Jetpack components, Retrofit, and Room. Understanding how coroutines actually work—beyond simple launch and await calls—is essential for writing apps that are responsive, safe, and easy to debug. This tutorial walks through the core concepts you need to master, from scope management to reactive streams, with runnable code you can drop into an Android project.
Coroutine Scopes
A coroutine scope defines the lifecycle boundary for all child coroutines. Without a scope, you have no way to cancel work when it's no longer needed, and you risk leaking memory or updating UI after an activity is destroyed. On Android, you typically tie coroutine scopes to component lifecycles using LifecycleScope from the lifecycle-runtime-ktx library.
// build.gradle.kts (app module)
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
}
This dependency block adds the coroutines runtime with Android integration and the lifecycle-scoped coroutine extensions. The coroutines-android artifact is what gives you the Dispatchers.Main implementation that ties into the Android main thread looper.
Inside a ComponentActivity, the lifecycleScope property is available directly. Any coroutine launched there is automatically cancelled when the activity's ON_DESTROY event fires. The same pattern applies to fragments, viewmodels, and services through their respective scope extensions. You should never call GlobalScope.launch in application code—it creates a coroutine with no lifecycle owner, meaning it runs until completion regardless of what happens to your UI. If you need a scope that lives longer than a single component, use a ViewModelScope or a manually managed CoroutineScope tied to a known lifecycle event.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
lifecycleScope.launch {
val result = fetchUserData()
binding.textOutput.text = result.name
}
}
}
In this example, lifecycleScope ensures the coroutine is cancelled if the user navigates away before fetchUserData completes. The binding reference is safe because the scope is bound to the activity's creation and destruction cycle. If you were to replace lifecycleScope with GlobalScope, the coroutine could try to update binding.textOutput after the view hierarchy has been torn down, causing a crash or a silent no-op depending on the timing.
Dispatchers
Dispatchers control which thread a coroutine executes on. Android provides four built-in dispatchers, and picking the right one is as important as getting the logic correct. Dispatchers.Main runs on the UI thread and is required for any operation that touches views or LiveData. Dispatchers.IO is optimized for disk and network work—it uses a shared pool of threads that can grow to handle blocking I/O. Dispatchers.Default targets CPU-intensive computations like parsing large data sets or running image transformations. Dispatchers.Unconfined lets a coroutine start on whatever thread it is called from and then resume wherever the suspending function happens to complete; it is almost never the right choice in production code.
suspend fun fetchUserData(): UserData {
return withContext(Dispatchers.IO) {
// Simulate a network call
delay(500L)
UserData("Agnes", "Senior Android Engineer")
}
}
data class UserData(val name: String, val role: String)
This suspending function uses withContext to switch execution to the IO dispatcher. Unlike launch or async, withContext returns a value directly, which is why it is the idiomatic choice for wrapping a blocking or network operation. The caller of fetchUserData does not need to know which dispatcher was used internally—the suspension point handles the thread switch transparently.
class DetailViewModel : ViewModel() {
private val _uiState = MutableStateFlow<DetailState>(DetailState.Loading)
val uiState: StateFlow<DetailState> = _uiState
fun loadDetail(userId: String) {
viewModelScope.launch(Dispatchers.IO) {
val user = apiClient.fetchUser(userId)
val posts = apiClient.fetchPosts(userId)
_uiState.value = DetailState.Success(user, posts)
}
}
}
sealed class DetailState {
object Loading : DetailState()
data class Success(
val user: UserData,
val posts: List<Post>
) : DetailState()
}
The viewModelScope keeps the coroutine alive as long as the ViewModel is valid, which is the correct pattern for data loading logic. Note that Dispatchers.IO is passed explicitly to launch here because the body contains only I/O-bound calls. However, the assignment to _uiState.value happens on the IO thread. StateFlow's collector runs on the Main dispatcher (see the observe example below), and StateFlow itself is thread-safe—so this code works, but it is not the cleanest pattern. In practice, you would either collect from the UI thread and let StateFlow handle the threading, or use withContext(Dispatchers.Main) before emitting. The point is that understanding where each line runs lets you reason about correctness and performance.
Structured Concurrency
Structured concurrency is the principle that every coroutine has a clear parent, a clear scope, and a clear lifetime. When a parent coroutine is cancelled, all of its children are cancelled automatically. This property eliminates the class of bugs where background work outlives the UI that spawned it. The coroutine hierarchy is visualizable as a tree: GlobalScope is the root, and every scope.launch or scope.async call creates a child node. If any node fails with an exception, the entire subtree is cancelled, and the failure is propagated back to the parent.
fun startWork(scope: CoroutineScope) {
scope.launch {
launch {
delay(1000L)
println("Child 1 finished")
}
launch {
delay(2000L)
println("Child 2 finished")
}
println("Parent finished")
}
println("Work started")
}
In this example, the two inner launch calls inherit the scope of the outer launch. If the outer scope is cancelled before either child completes, both children are cancelled as well. You do not need to track or cancel them individually. This is the core benefit of structured concurrency: cancellation is automatic and exhaustive. It also means exceptions are not silently swallowed—if one child throws and the parent is still active, the parent's exception handler can catch it, or the parent itself will be cancelled if no handler is present.
Cancellation
Cancellation in coroutines is cooperative. A coroutine must regularly check whether it has been cancelled, and most built-in suspending functions do this check automatically. Functions like delay, suspendCancellableCoroutine, and the Kotlin coroutines network helpers all respect cancellation. However, infinite loops and blocking calls do not check cancellation by default, which is why you must be careful when wrapping legacy code.
fun CoroutineScope.fetchAndProcess() {
launch {
try {
val data = httpClient.getJson("/api/data")
processData(data)
} catch (e: CancellationException) {
println("Operation was cancelled: $e")
throw e
} catch (e: Exception) {
println("Operation failed: $e")
}
}
}
The key detail here is that CancellationException is re-thrown. The coroutine framework treats this exception specially—it is the signal that cancellation succeeded, not a failure. If you catch and suppress it, the coroutine appears to complete normally while its children may still be running, which breaks structured concurrency guarantees. Always re-throw CancellationException from your catch blocks. The second catch clause handles genuine failures such as network errors or JSON parse exceptions.
fun CoroutineScope.watchForChanges() {
launch {
while (isActive) {
checkPermissions()
delay(5000L)
}
}
}
Manual loops like this one must explicitly check isActive (or use ensureActive()) because while(true) with a delay inside does not automatically check cancellation between iterations on all coroutine implementations. The isActive property returns false the moment the scope is cancelled, causing the loop to exit cleanly. An alternative is repeat(Int.MAX_VALUE) { delay(5000L) }, which is equivalent and slightly more concise.
Exception Handling
When a coroutine fails, the exception propagates up the coroutine hierarchy. If the failing coroutine is the last child of its parent and has no sibling handlers, the parent is cancelled and the exception bubbles further up. At the top level, unhandled exceptions are caught by the CoroutineExceptionHandler, which is a context element you attach explicitly.
val handler = CoroutineExceptionHandler { _, exception ->
Log.e("Coroutine", "Unhandled exception: $exception")
}
fun CoroutineScope.loadData() {
launch(handler) {
val user = apiClient.fetchUser("123")
val orders = apiClient.fetchOrders("123")
render(user, orders)
}
}
The handler is attached via the coroutine's CoroutineContext, not as a traditional try-catch. This works because the handler is consulted when a coroutine terminates due to an uncaught exception. If fetchUser throws, the fetchOrders call never runs, and the handler logs the error. If both calls succeed but render throws, the same handler catches it. The handler is a safety net, not a replacement for proper error modeling—use sealed classes or Result types to surface expected failures to your UI.
fun CoroutineScope.fetchWithFallback() {
launch {
try {
val data = apiClient.fetchUser("123")
_state.value = UiState.Success(data)
} catch (e: Exception) {
_state.value = UiState.Error(e.message ?: "Unknown error")
}
}
}
This pattern keeps error handling local and explicit. The UI observes _state and renders the appropriate screen. Unlike a global handler, a local catch lets you transform the exception into domain-specific UI feedback. Use both strategies together: local catches for recoverable errors, and a top-level handler as a last resort for unexpected crashes.
StateFlow and SharedFlow
StateFlow and SharedFlow are the coroutine-native replacements for LiveData and EventBus. StateFlow holds a single, current value that new collectors always receive immediately upon subscription. SharedFlow is a multi-event stream that can replay a configurable number of recent values to late subscribers. Both are built on coroutines and integrate naturally with structured concurrency.
// activity_detail.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/textOutput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp" />
<Button
android:id="@+id/btnLoad"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Load Data" />
</LinearLayout>
This layout provides the UI elements that the next code example will bind to. The TextView displays streamed values, and the Button triggers data loading. No special attributes are needed—this is standard Android XML.
class DetailActivity : AppCompatActivity() {
private lateinit var binding: ActivityDetailBinding
private val viewModel: DetailViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityDetailBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.btnLoad.setOnClickListener {
viewModel.loadDetail("user-42")
}
lifecycleScope.launch {
viewModel.uiState.collect { state ->
when (state) {
is DetailState.Loading -> {
binding.textOutput.text = "Loading..."
}
is DetailState.Success -> {
binding.textOutput.text = "${state.user.name} — ${state.posts.size} posts"
}
is DetailState.Error -> {
binding.textOutput.text = "Error: ${state.message}"
}
}
}
}
}
}
The collect call suspends until the flow is cancelled, which happens when the activity is destroyed because the collection is scoped to lifecycleScope. Each emitted DetailState value is handled by the when expression, which covers all possible states exhaustively. Because uiState is a StateFlow, the collector receives the latest value immediately upon subscription, so there is no race condition between the activity becoming visible and the first emission.
class EventViewModel : ViewModel() {
private val _events = MutableSharedFlow<Event>(extraBufferCapacity = 64)
val events: SharedFlow<Event> = _events
suspend fun reportEvent(event: Event) {
_events.emit(event)
}
sealed class Event {
data class Navigation(val screen: String) : Event()
data class Toast(val message: String) : Event()
data class Snackbar(val message: String) : Event()
}
}
SharedFlow differs from StateFlow in that it carries events, not state. The extraBufferCapacity parameter ensures that up to 64 events can be buffered without blocking the emitter. Late subscribers who join after some events have already been emitted will not see those missed events unless you configure replay, which is set to 0 by default. This makes SharedFlow ideal for one-shot actions like navigation commands or toast messages, where delivering a stale event is worse than dropping it entirely.
Common Pitfalls
New coroutine users frequently stumble over a handful of recurring mistakes. One of the most common is calling withContext(Dispatchers.Main) inside a function that is already running on the main thread—withContext will detect this and execute the block inline, which is harmless but signals a misunderstanding of when dispatcher switches are actually needed. Another frequent error is forgetting to re-throw CancellationException, which silently swallows cancellation and leaves child coroutines running after their parent scope is gone. A third pitfall is using GlobalScope to launch work that should be scoped to a lifecycle; this creates background tasks that outlive the app's UI and can leak memory. Finally, collecting a StateFlow or SharedFlow outside of a coroutine scope—such as directly in an init block without a launch—will not compile, because collect is a suspending function and can only be called from within a coroutine or another suspending function.
Conclusion
Kotlin coroutines give you a structured, composable way to manage asynchronous work on Android. By anchoring your coroutines to well-defined scopes, choosing the right dispatcher for each unit of work, respecting cancellation, and handling exceptions deliberately, you build apps that are both responsive and resilient. StateFlow and SharedFlow extend these principles into the reactive world, letting you model state and events with the same lifecycle awareness that scopes provide for plain coroutines. Master these fundamentals, and the rest of the coroutine API—async, supervisorScope, actor, channel—becomes a natural extension of the patterns you already understand.