14 March 2026
Structured concurrency is, in my opinion, the single biggest innovation Kotlin coroutines brought to Android development. Not suspend fun. Not Flow. Not even the CPS transformation that turns sequential-looking code into a state machine. The thing that actually changed how I build apps is the rule that every coroutine must have a scope, and when that scope dies, everything inside it dies too. It sounds simple â almost obvious â but it solves a class of bugs that plagued Android for a decade.
Before coroutines, the âfire and forget and leakâ problem was just part of the job. Youâd launch something on a background thread, the user would rotate the screen or press back, and that background task would keep running with a reference to a dead Activity. I once spent an entire day tracking down a bug where a network callback was updating a fragmentâs views after onDestroyView had already been called. The fix was three lines of cleanup code that Iâd forgotten to write. Structured concurrency makes that category of bug structurally impossible â not âharder to write,â but impossible. The runtime enforces the cleanup you used to forget.
Think about what happens without structured concurrency. Youâre in an Activity, you start a network call on a background thread, the user presses back. The Activity is destroyed, but the thread doesnât care â it has no concept of the Activityâs lifecycle. The network call completes, the callback fires, it tries to call findViewById on a destroyed Activity. You get a NullPointerException, or worse, you get nothing â the thread silently updates a reference to a garbage-collected object, and the data is lost.
// The old way â fire and forget
class ProfileActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
loadProfile()
}
private fun loadProfile() {
Thread {
val user = api.fetchUser() // Takes 5 seconds
runOnUiThread {
// Activity might be destroyed by now
// This either crashes or silently fails
nameTextView.text = user.name
}
}.start()
}
}
This is a toy example, but the real-world version is nastier. In production, youâd have a thread pool executing multiple tasks, each holding references to various UI components. The RxJava era improved things with CompositeDisposable, but the cleanup was manual â add every subscription to a disposable, call clear() in onDestroy. Forget one subscription and you have a leak. Iâve reviewed codebases where onDestroy was 40 lines of manual cleanup. Thatâs 40 lines of code that exist only because the concurrency model didnât understand lifecycles.
Structured concurrency flips the model. Instead of âlaunch work and remember to cancel it later,â you say âlaunch work inside this scope.â The scope is tied to a lifecycle. When the lifecycle ends, the scope cancels, and every coroutine inside it gets cancelled. There is no cleanup code to write because the cleanup is the structure itself.
Every coroutine in Kotlin lives inside a CoroutineScope. The scope isnât just a launcher â itâs a lifecycle boundary. When the scope is cancelled, every coroutine launched in it receives a cancellation signal. No scope, no coroutine. The compiler enforces this â you literally cannot call launch or async without a scope.
Androidâs Jetpack libraries provide scopes that are already wired to the right lifecycles. viewModelScope cancels all its coroutines when the ViewModelâs onCleared() is called. lifecycleScope cancels when the lifecycle owner (Activity or Fragment) reaches the DESTROYED state. You donât register cleanup callbacks. You donât call cancel() manually. The framework does it.
class SearchViewModel(
private val searchRepository: SearchRepository
) : ViewModel() {
private val _results = MutableStateFlow<List<SearchResult>>(emptyList())
val results: StateFlow<List<SearchResult>> = _results.asStateFlow()
fun search(query: String) {
viewModelScope.launch {
val data = searchRepository.search(query)
_results.value = data
}
// User navigates away, ViewModel is cleared â
// viewModelScope cancels, search coroutine is cancelled.
// No manual cleanup. No leaked network call.
}
}
Hereâs the thing most people miss: viewModelScope internally uses SupervisorJob() + Dispatchers.Main.immediate. The SupervisorJob means one failed coroutine in the scope doesnât cancel all siblings â crucial for ViewModels where you might have multiple independent operations running. The Dispatchers.Main.immediate means the coroutine starts on the main thread without re-dispatching if youâre already on main, which avoids an unnecessary frame delay. These are deliberate choices by the AndroidX team, and understanding them helps you know when to create your own scopes with different configurations.
The hierarchy looks like this: a scope contains coroutines, each coroutine has a Job, and that Job is a child of the scopeâs Job. When the scopeâs Job is cancelled, it walks the entire tree and cancels every child. This tree structure is what makes structured concurrency âstructuredâ â itâs not a flat list of tasks, itâs a hierarchy that mirrors the lifecycle hierarchy of your app.
Jobs in coroutines form a tree. This is where structured concurrency gets its real power â and where most of the confusion lives. Every launch or async creates a new Job that becomes a child of the current coroutineâs Job. Cancelling a parent cancels all children. A childâs failure propagates to the parent (unless the parent has a SupervisorJob). This propagation is what makes exceptions in coroutines feel different from regular Kotlin exceptions.
class OrderViewModel(
private val orderRepository: OrderRepository,
private val analyticsService: AnalyticsService
) : ViewModel() {
fun placeOrder(order: Order) {
viewModelScope.launch { // Parent coroutine (Job A)
launch { // Child coroutine (Job B)
orderRepository.submitOrder(order)
}
launch { // Child coroutine (Job C)
analyticsService.trackOrderPlaced(order.id)
}
// If Job B fails:
// - With regular Job: Job B's failure cancels Job A,
// which cancels Job C. Everything stops.
// - With SupervisorJob (what viewModelScope uses at the top):
// Job B's failure does NOT cancel Job C.
}
}
}
The subtlety here is that viewModelScope has a SupervisorJob at its root, but the launch inside it creates a regular Job. So if submitOrder throws, that exception propagates to the parent launch block, which cancels the analytics child. The SupervisorJob at the viewModelScope level prevents it from tearing down other top-level launch calls in the same ViewModel â but within a single launch block, sibling children still cancel each other.
This behavior is intentional. The coroutines team at JetBrains designed it this way because within a single logical operation (one launch block), if part of the operation fails, the whole operation should fail. You donât want half an order submission to succeed while the other half silently fails. But across independent operations (separate launch blocks in a ViewModel), one failure shouldnât nuke everything else. The SupervisorJob at the scope level draws that boundary.
These two scope builders look similar but behave very differently when children fail.
coroutineScope follows the strict structured concurrency contract: if any child fails, it cancels all other children and rethrows the exception. This is what you want when all the children are part of one logical operation that should succeed or fail as a unit.
supervisorScope isolates failures: if one child fails, other children keep running. The failed childâs exception doesnât propagate to siblings. This is what you want when children are independent operations that happen to run at the same time.
class DashboardViewModel(
private val userRepository: UserRepository,
private val feedRepository: FeedRepository,
private val notificationRepository: NotificationRepository
) : ViewModel() {
fun loadDashboard() {
viewModelScope.launch {
// supervisorScope â each section loads independently
supervisorScope {
val userDeferred = async { userRepository.getProfile() }
val feedDeferred = async { feedRepository.getLatestFeed() }
val notifDeferred = async { notificationRepository.getUnreadCount() }
// If feed fails, user and notifications still complete
_userState.value = runCatching { userDeferred.await() }
.getOrElse { UserState.Error }
_feedState.value = runCatching { feedDeferred.await() }
.getOrElse { FeedState.Error }
_notifState.value = runCatching { notifDeferred.await() }
.getOrElse { NotifState.Error }
}
}
}
}
Compare this with coroutineScope â if the feed API throws, it cancels the user and notification calls too. Thatâs terrible UX. The user sees a blank dashboard because one out of three API calls failed. With supervisorScope, the user profile and notification count still load, and only the feed section shows an error.
But hereâs the tradeoff: with supervisorScope, you must handle each childâs failure individually. If you use coroutineScope, a single try-catch around the whole block handles everything. With supervisorScope, you need runCatching or individual try-catch blocks around each await(). More control, more code.
I use coroutineScope when all the work is part of one transaction â like fetching data and then writing it to the database. If the fetch fails, I donât want the write to proceed with stale data. I use supervisorScope when Iâm loading independent UI sections in parallel, where partial success is better than total failure.
viewModelScope and lifecycleScope cover most cases, but sometimes you need a scope that outlives a screen but doesnât live forever. Repositories, sync services, and app-level operations need their own scopes. The mistake I see most often is reaching for GlobalScope.
GlobalScope is dangerous because it throws away structured concurrency. A coroutine launched in GlobalScope has no parent â nothing cancels it automatically. Memory leaks, stale operations, race conditions â all the problems structured concurrency solved come right back.
// Never do this
fun syncData() {
GlobalScope.launch {
repository.syncAll() // Runs until completion, no lifecycle awareness
}
}
The alternative is creating a custom scope with explicit lifecycle management. For an application-scoped scope, pair it with SupervisorJob so individual failures donât cascade.
@Singleton
class AppCoroutineScope @Inject constructor() : CoroutineScope {
override val coroutineContext: CoroutineContext =
SupervisorJob() + Dispatchers.Default + CoroutineName("AppScope")
}
class SyncService @Inject constructor(
private val repository: DataRepository,
private val appScope: AppCoroutineScope
) {
fun startPeriodicSync() {
appScope.launch {
while (isActive) {
repository.syncAll()
delay(15.minutes)
}
}
}
}
For repository-scoped operations â work that should outlive a ViewModel but not the repository â create a scope owned by the repository and cancel it when the repository is no longer needed. In a Hilt-based app, a @ActivityRetainedScoped repositoryâs scope naturally outlives configuration changes but gets cleaned up when the activity is truly finished.
The key insight is that every scope should map to a real lifecycle in your app. If you canât point to a moment when the scope should be cancelled, youâre probably using the wrong scope. viewModelScope maps to âwhile this screenâs ViewModel exists.â An app scope maps to âwhile the process is running.â A repository scope maps to âwhile this feature is active.â The scope is documentation â it tells future developers exactly how long this work is supposed to live. And in testing, custom scopes become trivial to control â inject one backed by StandardTestDispatcher instead of the real scope, and your tests are deterministic.
coroutineScope?Explanation:
coroutineScopefollows strict structured concurrency â if any child fails, it cancels all other children in the scope and rethrows the exception. This ensures the entire operation fails as a unit rather than producing partial results. UsesupervisorScopeif you need failure isolation between children.
GlobalScope in Android apps?Explanation:
GlobalScopelaunches coroutines with no parent Job, so nothing cancels them automatically when a component is destroyed. This brings back the same memory leak and stale-update bugs that structured concurrency was designed to prevent. Create a custom scope withSupervisorJoband proper lifecycle management instead.
Build a DashboardLoader that loads three independent data sources in parallel using supervisorScope. If any single source fails, the others should still complete successfully. Each result should be wrapped in a sealed class representing success or failure. The loader should use a custom CoroutineScope (not GlobalScope) and support cancellation. Include a cancel() method that tears down all running work.
sealed class LoadResult<out T> {
data class Success<T>(val data: T) : LoadResult<T>()
data class Failure(val error: Throwable) : LoadResult<Nothing>()
}
class DashboardLoader(
private val userRepo: UserRepository,
private val feedRepo: FeedRepository,
private val statsRepo: StatsRepository
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
suspend fun loadAll(): Triple<LoadResult<User>, LoadResult<Feed>, LoadResult<Stats>> {
return scope.async {
supervisorScope {
val user = async {
runCatching { userRepo.getProfile() }
.fold({ LoadResult.Success(it) }, { LoadResult.Failure(it) })
}
val feed = async {
runCatching { feedRepo.getLatest() }
.fold({ LoadResult.Success(it) }, { LoadResult.Failure(it) })
}
val stats = async {
runCatching { statsRepo.getDashboardStats() }
.fold({ LoadResult.Success(it) }, { LoadResult.Failure(it) })
}
Triple(user.await(), feed.await(), stats.await())
}
}.await()
}
fun cancel() {
scope.cancel()
}
}
Thanks for reading!