What is “Structured Concurrency”?
Think of coroutines like kids on a school trip. Structured concurrency says:
- Kids (child coroutines) must stay inside the group (a scope).
- The trip leader (the parent coroutine) knows where every kid is.
- The bus leaves only when all kids are back (the scope completes).
- If the leader cancels the trip, all kids come back immediately (cancellation propagates).
This makes your async code safe, traceable, and clean—no lost tasks, no hidden errors.
The 3 golden rules
- Every coroutine lives in a scope.
UsecoroutineScope{},supervisorScope{}, or aCoroutineScope(Job()). - Parents wait for children.
A scope won’t finish until its child coroutines finish. - Cancellation/Failure flows down (or is supervised).
By default, one child’s failure cancels siblings. Use supervision when you don’t want that.
Two must-know scopes
1) coroutineScope { ... } (default, strict)
- If a child fails, all children are cancelled and the parent fails.
suspend fun fetchProfile(): Profile = coroutineScope {
val user = async { api.getUser() } // child 1
val posts = async { api.getPosts() } // child 2
// If getPosts() throws, both children cancel, scope throws.
Profile(user.await(), posts.await())
}
2) supervisorScope { ... } (for independent tasks)
- One child’s failure does not cancel others.
- Parent completes if you handle errors.
suspend fun loadDashboard(): Dashboard = supervisorScope {
val news = async { api.getNews() } // might fail
val weather = async { api.getWeather() } // might succeed
val safeNews = runCatching { news.await() }.getOrDefault(emptyList())
val safeWeather = runCatching { weather.await() }.getOrNull() ?: Weather.Empty
Dashboard(safeNews, safeWeather)
}
launch vs async (say it like a pro)
launch→ fire-and-forget, returnsJob, use when you don’t need a result.async→ returnsDeferred<T>, use when you need a result viaawait().
coroutineScope {
val j = launch { log.send("analytics") } // no result
val data: Deferred<List<Item>> = async { repo.loadItems() } // need result
val items = data.await()
j.join()
}
Tip: If you call
async { ... }and neverawait(), you’ve probably created a bug.
Cancellation that actually works
- Always use suspending functions (like
delay, Retrofit’s suspend APIs). - Check with
ensureActive()in long loops.
suspend fun searchAll(pages: Int): List<Result> = coroutineScope {
(1..pages).map { page ->
async {
coroutineContext.ensureActive()
api.search(page) // cooperates with cancellation
}
}.awaitAll()
}
Cancel the parent to cancel everything:
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
val job = scope.launch { loadDashboard() }
// later...
job.cancel() // all children in this subtree cancel
Exceptions: who catches what?
- In
coroutineScope, any child’s exception cancels siblings and is rethrown. - In
supervisorScope, child exceptions are isolated. You must handle them.
suspend fun strictFlow() = coroutineScope {
try {
val a = async { workA() }
val b = async { workB() } // if this throws, 'a' cancels too
a.await() + b.await()
} catch (e: Exception) {
// whole scope failed
throw e
}
}
suspend fun tolerantFlow() = supervisorScope {
val a = async { runCatching { workA() }.getOrDefault(0) }
val b = async { runCatching { workB() }.getOrDefault(0) }
a.await() + b.await() // both awaited, even if one failed
}
Android quick start
Use already-scoped helpers that implement structured concurrency for you:
class MyViewModel : ViewModel() {
fun load() = viewModelScope.launch {
val user = async { repo.user() }
val feed = async { repo.feed() }
render(user.await(), feed.await())
}
}
// In Fragments/Activities use lifecycleScope or repeatOnLifecycle
Why it’s safe:
viewModelScopecancels work when the ViewModel is cleared.- No leaking coroutines after navigation or rotation.
Timeouts and supervisors
suspend fun resilientLoad(): Data = supervisorScope {
val a = async { withTimeout(800) { repo.partA() } }
val b = async { withTimeout(800) { repo.partB() } }
val safeA = runCatching { a.await() }.getOrNull()
val safeB = runCatching { b.await() }.getOrNull()
Data(safeA, safeB) // partial results > total failure
}
Common mistakes (and easy fixes)
- ❌ Launching on
GlobalScope(easy leak)
✅ Use a real scope tied to a lifecycle:viewModelScope,lifecycleScope, or your ownCoroutineScope(Job()). - ❌
asyncwithoutawait()
✅ Always await (orawaitAll()), or switch tolaunch. - ❌ Ignoring exceptions in
launch
✅ Wrap withtry/catchinside the coroutine or use aCoroutineExceptionHandler. - ❌ Blocking calls inside coroutines
✅ Use suspend APIs or wrap withwithContext(Dispatchers.IO)when truly blocking.
Mini interview prep
Q1. What is structured concurrency?
A design where coroutines run in explicit scopes; parents track/await children; cancellation and errors propagate predictably.
Q2. coroutineScope vs supervisorScope?coroutineScope cancels siblings on the first failure; supervisorScope isolates failures so other children continue.
Q3. launch vs async?launch returns Job (no result); async returns Deferred<T> (result via await()).
Q4. How do you avoid leaks on Android?
Use viewModelScope / lifecycleScope instead of GlobalScope; tie work to lifecycle.
Q5. How do you handle partial success?
Use supervisorScope + runCatching (or individual try/catch) and combine available parts.
Quick checklist
- Every coroutine is launched in a known scope
- Avoid
GlobalScope - Choose
coroutineScope(all-or-nothing) orsupervisorScope(best-effort) asyncis always awaited- Cancellation tested (timeout or manual)
- Exceptions handled close to where they happen
- Android: use
viewModelScope/lifecycleScope
Structured Concurrency In Coroutine lets Kotlin apps run concurrent tasks safely, with predictable cancellation, exception handling, and lifecycle awareness. Learn the differences between coroutineScope vs supervisorScope, when to use launch vs async, and how to handle failures gracefully—perfect for Android, backend Kotlin, and anyone following the latest coroutine best practices .
Copy-ready snippets
A. Strict, all-or-nothing load
suspend fun loadStrict(): Result = coroutineScope {
val a = async { repo.a() }
val b = async { repo.b() }
Result(a.await(), b.await()) // throws if any child fails
}
B. Resilient, partial results
suspend fun loadResilient(): Result = supervisorScope {
val a = async { runCatching { repo.a() }.getOrNull() }
val b = async { runCatching { repo.b() }.getOrNull() }
Result(a.await(), b.await())
}
C. Lifecycle-aware ViewModel
class ProfileVM(
private val repo: Repo
) : ViewModel() {
fun refresh() = viewModelScope.launch {
val profile = async { repo.profile() }
val photos = async { repo.photos() }
show(profile.await(), photos.await())
}
}
Final thought
If you remember only one thing: put all coroutines inside a scope that matches your app’s lifecycle, then pick coroutineScope or supervisorScope based on whether tasks must succeed together or can succeed independently. That’s structured concurrency, the Kotlin way.

