What is structured concurrency in Kotlin?
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
CoroutineScope, and the scope decides when the work stops.
| Scope | Cancelled when | Source |
|---|---|---|
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 |
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.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
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).// 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
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".
| Builder | As a root coroutine | As a child of another coroutine |
|---|---|---|
launch | Uncaught exception, delivered to a CoroutineExceptionHandler if one is installed | Fails the parent, which cancels the siblings |
async | Held in the Deferred until await() | Fails the parent as well, whether or not anyone awaits |
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.// 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
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.@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
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.@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)
}
}Key Takeaways
- 1Structured concurrency binds a coroutine to its scope: cancel the scope and its coroutines are cancelled with it.
- 2Use viewModelScope for data work and rememberCoroutineScope for UI-only work launched from an event.
- 3Cancellation is cooperative. Check ensureActive() in CPU loops and never swallow CancellationException.
- 4A failed async child fails its parent whether or not you call await(). Only a root async waits.
- 5Use supervisorScope to isolate failures. launch(SupervisorJob()) detaches the coroutine and isolates nothing.
- 6A StateFlow built with stateIn and WhileSubscribed needs a collector in the test, or its value never changes.
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.
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.