SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:23:05
UP00:01
MOD · ARTICLE · KOTLINS/N · AX-KOTLINPUBLISHED
All Articles
Kotlin · Updated 12 MIN

Kotlin Coroutines Deep Dive: Structured Concurrency in Practice

Master structured concurrency, cancellation, exception handling, and Flow operators for production-grade Android applications.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

What is structured concurrency in Kotlin?

Most Android developers learn coroutines by calling launch inside a viewModelScope and moving on. That covers the first screen. It does not cover the cancelled search that still writes its result, or the one failed request that takes two healthy ones down with it. Structured concurrency is the rule behind both. A coroutine belongs to the scope that launched it: cancel the scope and everything in it is cancelled, and a failure in one coroutine travels through the same hierarchy. Three things follow from that rule, and each is a place where production code goes wrong: which scope you launch in, whether your code cooperates with cancellation, and where an exception goes when nobody catches it. Everything below was checked against kotlinx.coroutines 1.11.0, released on 8 May 2026 (releases).

Coroutine Scopes and Their Lifecycle

Every coroutine runs inside a CoroutineScope, and the scope decides when the work stops.
ScopeCancelled whenSource
viewModelScope"Any coroutine launched in this scope is automatically canceled if the ViewModel is cleared."Android
rememberCoroutineScope()"The scope will be cancelled when the call leaves the Composition."Android
The choice matters because the two outlive different things. A ViewModel "caches state and persists it through configuration changes" (Android), so a request launched in viewModelScope survives a rotation. A scope tied to the composition does not. Data work belongs in the ViewModel; a composition scope is for UI-only work such as showing a snackbar. Which state that ViewModel holds is a separate decision, covered in MVVM vs MVI. One detail is worth knowing before the exception section: viewModelScope is built from a SupervisorJob and Dispatchers.Main.immediate (androidx source). Coroutines you launch directly in it already fail independently of each other.
kotlin
class SearchViewModel(
    private val searchRepo: SearchRepository
) : ViewModel() {

    private val _results = MutableStateFlow<List<Result>>(emptyList())
    val results: StateFlow<List<Result>> = _results.asStateFlow()

    // viewModelScope survives configuration changes
    fun search(query: String) {
        viewModelScope.launch {
            // Cancelled automatically when the ViewModel is cleared
            _results.value = searchRepo.search(query)
        }
    }
}

@Composable
fun SearchScreen(snackbarHostState: SnackbarHostState) {
    // Bound to this point of the composition
    val scope = rememberCoroutineScope()

    Button(
        onClick = {
            // Launch from an event, never from the composable body
            scope.launch {
                snackbarHostState.showSnackbar("Search complete")
            }
        }
    ) { Text("Search") }
}

Cancellation: The Silent Contract

The Kotlin documentation is blunt about it: "In Kotlin, coroutine cancellation is cooperative. Coroutines react to cancellation only when they cooperate by suspending or checking for cancellation explicitly" (Kotlin). Calling cancel() stops nothing by itself. The same page explains why: "When a coroutine is canceled, it continues running until it reaches a point in the code where it may suspend." So a loop of pure CPU work with no suspension point runs to the end, cancelled or not. Check inside it with ensureActive() or isActive, or call yield(), which the documentation notes "throws a CancellationException" once the coroutine is cancelled. The second half of the contract is not swallowing that exception. A broad catch (e: Exception) around a suspending call catches cancellation too, and the coroutine carries on as if nothing happened. Android's guidance: "don't consume exceptions of type CancellationException (don't catch them, or always rethrow them if caught). Prefer catching specific exception types like IOException" (Android).
kotlin
// BAD: this loop ignores cancellation
suspend fun processLargeList(items: List<Item>) {
    for (item in items) {
        item.transform() // CPU work, no suspension point
    }
}

// GOOD: check for cancellation in tight loops
suspend fun processLargeList(items: List<Item>) = coroutineScope {
    for (item in items) {
        ensureActive() // Throws CancellationException if cancelled
        item.transform()
    }
}

// BAD: swallows cancellation along with real failures
try {
    repo.sync()
} catch (e: Exception) {
    showError(e)
}

// GOOD: catch what you can handle, let cancellation through
try {
    repo.sync()
} catch (e: IOException) {
    showError(e)
}

Exception Handling Strategies

This is where most coroutine code is wrong, including an earlier version of this article. The usual summary, that launch throws and async waits for await(), is only true for a root coroutine. The documentation says so in its first paragraph: "Coroutine builders come in two flavors: propagating exceptions automatically (launch) or exposing them to users (async and produce). When these builders are used to create a root coroutine, that is not a child of another coroutine, the former builders treat exceptions as uncaught exceptions".
BuilderAs a root coroutineAs a child of another coroutine
launchUncaught exception, delivered to a CoroutineExceptionHandler if one is installedFails the parent, which cancels the siblings
asyncHeld in the Deferred until await()Fails the parent as well, whether or not anyone awaits
The right-hand column is the one that surprises people. The Kotlin guide shows two async children inside a coroutineScope and observes "how both the first async and the awaiting parent are cancelled on failure of one of the children" (Kotlin). There are two ways out, and they are different tools: - supervisorScope makes failure travel one way. It "propagates the cancellation in one direction only and cancels all its children only if it failed itself". - CoroutineExceptionHandler is a last resort for logging. "You cannot recover from the exception in the CoroutineExceptionHandler", and it "is invoked only on uncaught exceptions". Installed on a child it does nothing: children "delegate handling of their exceptions to their parent coroutine", so the handler "installed in their context is never used". One pattern to delete on sight is launch(SupervisorJob()) { ... }. It looks like supervision and is not. Pass a different Job as the context of a new coroutine and "it overrides the Job of the parent scope", so the result "is not tied to the scope it was launched from and operates independently" (Kotlin). The new coroutine no longer stops when the ViewModel is cleared, and the children launched inside it still share one ordinary parent, so one failure still cancels the rest.
kotlin
// PROBLEM: one failure cancels everything
viewModelScope.launch {
    launch { loadUserProfile() }  // If this throws...
    launch { loadUserPosts() }    // ...this is cancelled
    launch { loadUserSettings() } // ...and so is this
}

// SOLUTION 1: supervisorScope isolates the children
viewModelScope.launch {
    supervisorScope {
        launch {
            try { loadUserProfile() }
            catch (e: IOException) { handleProfileError(e) }
        }
        launch {
            try { loadUserPosts() }
            catch (e: IOException) { handlePostsError(e) }
        }
    }
}

// SOLUTION 2: launch each one directly in viewModelScope,
// which already has a SupervisorJob. The handler works here
// because each coroutine is a direct child of the supervisor.
val handler = CoroutineExceptionHandler { _, exception ->
    analytics.logError(exception)
}
viewModelScope.launch(handler) { loadUserProfile() }
viewModelScope.launch(handler) { loadUserPosts() }

// WRONG: detaches from viewModelScope and isolates nothing
viewModelScope.launch(handler + SupervisorJob()) {
    launch { loadUserProfile() }
    launch { loadUserPosts() }
}

Flow: Reactive Streams Done Right

Kotlin Flow is the coroutine-based stream type, and it inherits everything above: collection happens in a scope, and cancelling the scope stops the stream. The comparison with the type it replaced on Android is in Flow vs LiveData. Search-as-you-type is the pattern worth knowing by heart, because it is cancellation used on purpose. flatMapLatest is documented this way: "When the original flow emits a new value, the previous flow produced by transform block is cancelled" (source). A stale request cannot overwrite a fresh one because the stale request no longer exists. Three details in the sample are easy to miss: - Both operators need an opt-in. In 1.11.0 debounce is still annotated @FlowPreview and flatMapLatest is @ExperimentalCoroutinesApi. The compiler will ask. - Catch IOException, not Exception. The inner flow is cancelled on every keystroke, and that cancellation must pass through. - WhileSubscribed(5000) is a 5 second grace period. The parameter "configures a delay (in milliseconds) between the disappearance of the last subscriber and the stopping of the sharing coroutine" (source). It defaults to zero.
kotlin
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class SearchViewModel(
    private val repo: SearchRepository
) : ViewModel() {

    private val _query = MutableStateFlow("")

    val results: StateFlow<SearchState> = _query
        .debounce(300) // Wait 300 ms after the last keystroke
        .filter { it.length >= 2 }
        .flatMapLatest { query ->
            // The previous search is cancelled when a new query arrives
            flow {
                emit(SearchState.Loading)
                try {
                    emit(SearchState.Success(repo.search(query)))
                } catch (e: IOException) {
                    emit(SearchState.Error(e.message))
                }
            }
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = SearchState.Idle
        )

    fun onQueryChanged(query: String) {
        _query.value = query
    }
}

sealed interface SearchState {
    data object Idle : SearchState
    data object Loading : SearchState
    data class Success(val results: List<Result>) : SearchState
    data class Error(val message: String?) : SearchState
}

Testing Coroutines

The kotlinx-coroutines-test library gives a test control of time. runTest skips delays, and advanceUntilIdle() "runs all enqueued tasks until there are no more". Replace the main dispatcher with Dispatchers.setMain and, per the README, "If mocked with a TestDispatcher, its TestCoroutineScheduler is used everywhere by default", which is what lets the 300 ms debounce inside the ViewModel pass in virtual time. For your own dispatchers the rule is injection. Android's guidance is "Don't hardcode Dispatchers when creating new coroutines or calling withContext" (Android). The trap is the stateIn flow from the previous section. A test that changes the query and then reads results.value sees Idle forever. Android documents the reason: "there must be at least one collector present during the test. Otherwise the stateIn operator doesn't start collecting the underlying flow, and the StateFlow's value will never be updated" (Android). The fix is an empty collector in backgroundScope, which is cancelled for you when the test ends. Once the plumbing is right, the logic behind the stream is a good candidate for property-based tests.
kotlin
@OptIn(ExperimentalCoroutinesApi::class)
class SearchViewModelTest {

    @Before
    fun setup() {
        Dispatchers.setMain(UnconfinedTestDispatcher())
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun `search emits results`() = runTest {
        val fakeRepo = FakeSearchRepository(
            results = listOf(Result("Kotlin"))
        )
        val viewModel = SearchViewModel(fakeRepo)

        // Without a collector, WhileSubscribed never starts the flow
        backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
            viewModel.results.collect {}
        }

        viewModel.onQueryChanged("Kotlin")
        advanceUntilIdle() // Skips the 300 ms debounce

        val state = viewModel.results.value
        assertIs<SearchState.Success>(state)
        assertEquals(1, state.results.size)
    }
}
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Structured concurrency binds a coroutine to its scope: cancel the scope and its coroutines are cancelled with it.
  2. 2Use viewModelScope for data work and rememberCoroutineScope for UI-only work launched from an event.
  3. 3Cancellation is cooperative. Check ensureActive() in CPU loops and never swallow CancellationException.
  4. 4A failed async child fails its parent whether or not you call await(). Only a root async waits.
  5. 5Use supervisorScope to isolate failures. launch(SupervisorJob()) detaches the coroutine and isolates nothing.
  6. 6A StateFlow built with stateIn and WhileSubscribed needs a collector in the test, or its value never changes.
MOD · FAQ3 ENTRIESANSWERED

Frequently Asked

What is the difference between launch and async?

launch returns a Job and treats a failure as an uncaught exception. async returns a Deferred and, as a root coroutine, holds the failure until you call await(). As a child of another coroutine the two behave alike: either one fails its parent immediately, and the parent then cancels the remaining children.

How do I handle exceptions in coroutines?

Catch specific types such as IOException around the suspending call, and never swallow CancellationException. Use supervisorScope when one child failing should not cancel the others. Treat CoroutineExceptionHandler as a logging hook for uncaught exceptions only: the Kotlin documentation states that you cannot recover from an exception inside it.

Why does my StateFlow test only see the initial value?

A StateFlow created with stateIn and SharingStarted.WhileSubscribed does not collect its upstream until something subscribes. Reading .value does not count as a subscriber. Launch an empty collector in backgroundScope before you act on the ViewModel, then call advanceUntilIdle() so the debounce delay passes in virtual time.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA