← Back to list

Coroutines in Production Android: Scopes, Dispatchers, and Structured Concurrency

What every Android engineer should fully understand before launching another coroutine

Rituraj Sambherao · 2026-05-28 17:20 · 10 claps · 8.0 min read
#android #kotlin #kotlin-coroutines #android-development #mobile-architecture
Open on Medium ↗
Wiki topics: 📱 · Mobile Development 🏛️ · Architecture

Coroutines in Production Android: Scopes, Dispatchers, and Structured Concurrency

What every Android engineer should fully understand before launching another coroutine

The Gap Between Using Coroutines and Understanding Them

Coroutines are easy to start using. Add the dependency, call viewModelScope.launch, write suspend functions, collect flows. The basics are approachable and the happy path works well.

The problems appear later. A coroutine that keeps running after the screen is gone. A cancellation that does not propagate the way you expected. A dispatcher chosen because it felt right rather than because it was correct. An exception that swallows silently instead of surfacing.

These are not beginner mistakes. They show up in codebases written by experienced engineers who learned enough to be productive but never fully understood what the scope, the dispatcher, and the job are actually doing.

This article covers that foundation. Not as an academic exercise but as the practical knowledge that prevents a category of production bugs.

Structured Concurrency: The Idea That Holds Everything Together

Structured concurrency is the design principle that every coroutine belongs to a scope, and when that scope is cancelled every coroutine inside it is cancelled too. Work does not leak outside its defined boundary.

This sounds simple but it is the idea that makes coroutines safe to use in Android. Without it, a coroutine launched from a screen could outlive that screen, hold references to it, and cause memory leaks or crashes when it tries to update UI that no longer exists.

The relationship is hierarchical. A scope contains jobs. Jobs contain child jobs. When a parent is cancelled, all its children are cancelled. When a child fails, the failure can propagate to the parent depending on the job type.

val scope = CoroutineScope(Job())

scope.launch {
    // Child coroutine 1
    launch {
        // Grandchild coroutine
        delay(5000)
        println("This will never print if scope is cancelled")
    }
}

scope.cancel() // Cancels everything in the hierarchy

This hierarchy is what makes viewModelScope safe. When the ViewModel is cleared, the scope is cancelled, and every coroutine launched from it stops. The screen is gone and so is all the work it started.

Scopes and Their Lifecycles

Choosing the right scope is choosing the right lifetime for your work. Every coroutine needs to answer the question: when should this stop?

viewModelScope

Tied to the ViewModel lifecycle. Cancelled when onCleared is called, which happens when the associated screen is permanently gone, not on configuration changes.

class OrderViewModel : ViewModel() {
    fun loadOrder(orderId: String) {
        viewModelScope.launch {
            val order = orderRepository.getOrder(orderId)
            _uiState.value = OrderUiState.Success(order)
        }
    }
}

This is the right scope for work that drives UI state. The work lives exactly as long as the ViewModel does.

lifecycleScope

Tied to the Activity or Fragment lifecycle. Cancelled when the lifecycle owner is destroyed. Useful for work that needs to respond to lifecycle events but does not belong in a ViewModel.

class OrderActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    renderState(state)
                }
            }
        }
    }
}

The repeatOnLifecycle call here is important. It restarts the inner block every time the lifecycle reaches the specified state and cancels it when it drops below. Without it, a flow collection that starts in onCreate continues even when the app is backgrounded and the UI is not visible.

rememberCoroutineScope

Tied to the composition lifecycle. Cancelled when the composable leaves the composition. Used for launching coroutines in response to user events from inside a composable.

@Composable
fun OrderScreen(viewModel: OrderViewModel = hiltViewModel()) {
    val scope = rememberCoroutineScope()

    Button(
        onClick = {
            scope.launch {
                viewModel.submitOrder()
            }
        }
    ) {
        Text("Submit Order")
    }
}

Use rememberCoroutineScope when you need to launch a coroutine from an event handler inside a composable. Do not use it for work that should outlive the composition or for collecting state flows. That is what the ViewModel is for.

LaunchedEffect

Not a scope you hold manually but a composition-scoped side effect that launches a coroutine when the composable enters the composition and cancels it when it leaves or when the key changes.

@Composable
fun OrderScreen(orderId: String, viewModel: OrderViewModel = hiltViewModel()) {
    LaunchedEffect(orderId) {
        viewModel.loadOrder(orderId)
    }
}

The key parameter controls when the effect reruns. If orderId changes, the current coroutine is cancelled and a new one starts. Use LaunchedEffect for one-shot operations that should run when a composable is first shown or when a key value changes.

GlobalScope

GlobalScope is a scope that lives for the entire lifetime of the application. It is not tied to any lifecycle and coroutines launched from it are never automatically cancelled.

There are very few legitimate reasons to use GlobalScope in an Android app. In almost every case there is a more appropriate scope. Using GlobalScope because it is convenient is how coroutine leaks happen.

kotlin

// Do not do this
GlobalScope.launch {
    orderRepository.submitOrder(order)
}

// The coroutine above will keep running even if the user
// navigates away, the ViewModel is cleared, or the screen is destroyed

If you find yourself reaching for GlobalScope, stop and ask which lifecycle this work should actually be tied to.

Dispatchers: Choosing the Right One

A dispatcher determines which thread or thread pool a coroutine runs on. The choice affects performance, correctness, and in some cases safety.

Dispatchers.Main

Runs on the Android main thread. Required for UI updates. Suspend functions that update UI state must run on the main thread. This is the default dispatcher for viewModelScope and lifecycleScope.

kotlin

viewModelScope.launch(Dispatchers.Main) {
    _uiState.value = OrderUiState.Loading // Safe on main thread
}

In practice you rarely need to specify Dispatchers.Main explicitly when using viewModelScope because it is already the default.

Dispatchers.IO

Optimised for IO-bound work. Backed by a thread pool that can scale up to 64 threads. Designed for network calls, database operations, and file reads and writes.

viewModelScope.launch {
    withContext(Dispatchers.IO) {
        val order = orderApi.getOrder(orderId) // Network call
        orderDao.insert(order.toEntity()) // Database write
    }
}

Dispatchers.IO is the right choice when the work involves waiting for an external resource. The thread pool is large because IO-bound work spends most of its time waiting, so many threads can be active simultaneously without overwhelming the CPU.

Dispatchers.Default

Optimised for CPU-bound work. Backed by a thread pool sized to the number of CPU cores. Designed for computation: sorting large lists, parsing complex data, running algorithms.

viewModelScope.launch {
    val sortedOrders = withContext(Dispatchers.Default) {
        orders.sortedByDescending { it.createdAt } // CPU work
    }
    _uiState.value = OrderUiState.Success(sortedOrders)
}

The common mistake is using Dispatchers.IO for CPU-bound work because it feels like the right non-main dispatcher. IO has a larger thread pool than necessary for CPU work and can actually perform worse for computation because of the overhead of managing more threads than the CPU can run simultaneously.

Dispatchers.Unconfined

Runs the coroutine in whatever thread called it, then resumes in whatever thread the suspension point completes on. Not tied to any specific thread. Rarely the right choice in production Android code. Mention it for completeness but do not reach for it.

withContext for switching dispatchers

withContext switches the dispatcher for a block of code and switches back when the block completes. It is the right way to move work off the main thread and back:

viewModelScope.launch { // Starts on Main
    _uiState.value = OrderUiState.Loading // Main thread

val order = withContext(Dispatchers.IO) {
        orderRepository.getOrder(orderId) // IO thread
    }
_uiState.value = OrderUiState.Success(order) // Back on Main

}

This pattern is clean and explicit. The work that needs IO runs on IO. The UI updates happen on Main. The coroutine handles the switching.

Job and SupervisorJob

Every coroutine has a Job that represents its lifecycle. The job can be in one of several states: active, completing, completed, cancelling, or cancelled. Understanding the difference between Job and SupervisorJob is about understanding how failures propagate through the coroutine hierarchy.

With a regular Job, if a child coroutine fails with an uncaught exception, the failure propagates to the parent and cancels all sibling coroutines:

kotlin

val scope = CoroutineScope(Job())

scope.launch {
    launch {
        throw RuntimeException("Child 1 failed")
    }
    launch {
        delay(1000)
        println("Child 2 - this will never print")
        // Cancelled because sibling failed
    }
}

With SupervisorJob, a child failure does not propagate to the parent or affect siblings:

val scope = CoroutineScope(SupervisorJob())

scope.launch {
    launch {
        throw RuntimeException("Child 1 failed")
    }
    launch {
        delay(1000)
        println("Child 2 - this will print")
        // Not affected by sibling failure
    }
}

viewModelScope uses SupervisorJob internally. This means a failed coroutine in a ViewModel does not cancel other coroutines in the same scope. Each operation is independent.

When should you use SupervisorJob explicitly? When you are building a scope where individual operations should be independent and a failure in one should not bring down the others. A background sync manager that runs multiple independent sync operations is a good example. A sequential flow where a failure in one step should stop subsequent steps is not.

Cooperative Cancellation

Coroutines in Kotlin are cooperatively cancelled. A coroutine is not forcibly stopped when its scope is cancelled. It is given a signal and it is expected to check for that signal and stop itself.

This means a coroutine that never checks for cancellation will keep running even after its scope is cancelled:

viewModelScope.launch {
    while (true) { // Never checks for cancellation
        processNextItem()
    }
}

If viewModelScope is cancelled while this coroutine is running, the loop continues indefinitely because it never yields to check the cancellation signal.

The fix is to check for cancellation regularly. Suspension points like delay, yield, and most IO operations do this automatically. For tight loops without suspension points, check explicitly:

viewModelScope.launch {
    while (isActive) { // Checks cancellation on each iteration
        processNextItem()
    }
}

Or use ensureActive() which throws CancellationException if the coroutine has been cancelled:

viewModelScope.launch {
    for (item in largeList) {
        ensureActive() // Throws if cancelled
        processItem(item)
    }
}

CancellationException is special in coroutines. It is not treated as a failure. It is the normal way a coroutine signals that it was cancelled. Do not catch it and swallow it:

// Do not do this
viewModelScope.launch {
    try {
        doWork()
    } catch (e: Exception) {
        // This catches CancellationException too
        // The coroutine thinks it completed normally
        // But its scope was cancelled
    }
}

// Do this instead
viewModelScope.launch {
    try {
        doWork()
    } catch (e: CancellationException) {
        throw e // Re-throw cancellation
    } catch (e: Exception) {
        handleError(e)
    }
}

Common Mistakes That Show Up in Production

Launching from the wrong scope

A coroutine launched from a scope that is too broad outlives its purpose. A coroutine launched from a scope that is too narrow gets cancelled before it finishes. Match the scope to the lifetime of the work.

Using GlobalScope for convenience

Almost always wrong in Android. There is a lifecycle-aware scope for every situation. Use it.

Catching CancellationException without rethrowing

Swallowing CancellationException breaks cooperative cancellation. The coroutine continues running in a cancelled scope. Always rethrow it.

Not using repeatOnLifecycle for flow collection

Collecting a flow in lifecycleScope.launch without repeatOnLifecycle means the collection continues when the app is backgrounded. Use repeatOnLifecycle(Lifecycle.State.STARTED) to automatically pause and resume collection with the lifecycle.

Assuming Dispatchers.IO is always the right non-main dispatcher

IO is for waiting on external resources. Default is for CPU work. Using IO for sorting a large list uses more threads than necessary and can perform worse than Default.

Creating a new scope manually when a lifecycle-aware one exists

CoroutineScope(Job()) created manually in a ViewModel has no automatic cancellation. Use viewModelScope instead.

The Scope Is the Contract

Every coroutine makes an implicit promise about its lifetime through the scope it runs in. viewModelScope promises the work stops when the ViewModel is cleared. rememberCoroutineScope promises the work stops when the composable leaves the composition. lifecycleScope with repeatOnLifecycle promises the work pauses and resumes with the lifecycle.

Understanding scopes is understanding those promises. When the scope is right, cancellation is automatic, leaks do not happen, and the coroutine hierarchy reflects the actual structure of the app. When the scope is wrong, work runs in the wrong context for the wrong duration and the bugs that follow are subtle enough to be genuinely difficult to diagnose.

The dispatcher is a performance and correctness decision. The scope is an architectural one. Get both right and coroutines are one of the cleanest concurrency tools available on any platform.


메타데이터
post_id
5b35cfa79660
slug
coroutines-in-production-android-scopes-dispatchers-and-structured-concurrency-5b35cfa79660
url
https://medium.com/@androidmeda/coroutines-in-production-android-scopes-dispatchers-and-structured-concurrency-5b35cfa79660
canonical_url
https://medium.com/@androidmeda/coroutines-in-production-android-scopes-dispatchers-and-structured-concurrency-5b35cfa79660
author_url
https://medium.com/@androidmeda
status
ok
fetched_at
2026-06-13 12:55:53