Modern applications are highly concurrent. We fetch APIs, read databases, update UI, and process background tasks — often at the same time.
But concurrency without structure becomes chaos.
In this blog, we’ll explore:
- What is Unstructured Concurrency
- Why we need Structured Concurrency
- The Fundamental Laws of Structured Coroutines
- Real example with android viewModel implementation
What Is Unstructured Concurrency?
Unstructured concurrency is when asynchronous work is started without clear ownership, lifecycle, or control.
It’s the classic: “Start it and hope for the best.”
Example using traditional threads:
Thread {
println("Doing background work")
}.start()
What’s wrong here?
- Who owns this thread?
- What if the screen is destroyed?
- How do we cancel it?
- What if it throws an exception?
- How do we wait for it?
You don’t have much control over:
- Lifecycle
- Cancellation
- Exception propagation
- Execution flow
Even error handling becomes messy:
Thread {
try {
// some work
} catch (e: Exception) {
// won't propagate to parent
}
}.start()
Exceptions don’t bubble up naturally. You lose structured error handling.
This is what we call Fire and Forget.
It may work in small programs — but in real systems (especially large Android apps), this becomes dangerous.
Why Traditional Threading Becomes Hard
Traditional threading:
- Doesn’t enforce hierarchy
- Doesn’t automatically propagate cancellation
- Doesn’t wait for child work
- Doesn’t provide predictable error handling
In complex systems:
- Memory leaks happen
- Background jobs continue after UI is gone
- Errors disappear silently
- Debugging becomes painful
As applications grow, this becomes unmanageable.
Why We Need Structured Concurrency
Structured concurrency solves these problems by introducing rules.
Instead of free-floating async work, we:
- Group related tasks together
- Track execution steps clearly
- Manage lifecycle properly
- Create a parent-child hierarchy
- Make cancellation predictable
- Make failure handling consistent
Think of it like this:
Unstructured concurrency = random background workers
Structured concurrency = organized team with a manager
The Core Idea: Parent-Child Hierarchy
Structured concurrency enforces a tree structure:
Parent
├── Child 1
│ └── Grandchild
└── Child 2
Rules:
- A parent controls its children
- A child cannot outlive its parent
- Errors propagate upward
- Cancellation flows downward
No more orphan coroutines.
What Is Structured Concurrency in Kotlin?
Structured concurrency is built into Kotlin coroutines using:
CoroutineScopecoroutineScopesupervisorScopelaunchasync
Every coroutine must run inside a scope.
That scope defines:
- Lifecycle
- Cancellation rules
- Error propagation
- Completion behavior
Fundamental Laws of Structured Coroutines
There are five fundamental laws.
1. Law of Scope Ownership
Every coroutine must run inside a scope.
Bad (unstructured):
GlobalScope.launch {
delay(1000)
}
Why bad?
- No lifecycle control
- Not tied to UI or business logic
- Hard to test
- Hard to cancel
Good:
coroutineScope {
launch {
delay(1000)
}
}
The coroutine now belongs to the scope.
2. Law of Parent Completion
A parent scope suspends until all children complete.
Example:
coroutineScope {
launch {
delay(1000)
println("Child done")
}
}
println("Parent done")
Output:
Child done
Parent done
The parent does not finish until the child finishes.
This guarantees predictable execution flow.
3. Law of Downward Cancellation
Cancellation flows from parent to children.
Example:
val job = launch {
launch {
delay(5000)
println("Child finished")
}
}
delay(1000)
job.cancel()
When the parent is cancelled:
- All children are cancelled automatically.
No orphan jobs.
4. Law of Upward Exception Propagation
Exceptions propagate from child to parent.
Example:
coroutineScope {
launch {
throw RuntimeException("Failure!")
}
launch {
delay(5000)
}
}
If one child fails:
- Parent is cancelled
- Sibling coroutines are cancelled
This enforces failure transparency.
You cannot silently ignore critical failures.
5. Law of Structured Boundaries
Concurrency must be bounded within a scope.
All async work must exist inside a well-defined boundary.
Not this:
fun loadData() {
GlobalScope.launch {
// background work
}
}
But this:
suspend fun loadData() = coroutineScope {
launch {
// background work
}
}
Now:
- The caller controls lifecycle
- The caller controls cancellation
- The caller can handle exceptions
What About Independent Failures?
Sometimes you don’t want one failure to cancel everything.
That’s where supervisorScope comes in:
supervisorScope {
launch {
throw RuntimeException("Fails")
}
launch {
delay(1000)
println("Still running")
}
}
Here:
- One child fails
- Other children continue
Useful for:
- Parallel API calls
- UI widgets
- Independent features
Real-World Benefit (Especially for Large Apps)
In large systems:
- Multi-module apps
- Multiple API orchestration
- Complex dashboards
- Feature-based architecture
Structured concurrency gives:
- Predictable lifecycle management
- Safer cancellation
- Cleaner error handling
- Easier testing
- No memory leaks
- No zombie background jobs
It turns asynchronous chaos into a structured tree.
Structured vs Unstructured – Final Comparison
| Feature | Unstructured | Structured |
|---|---|---|
| Lifecycle control | ❌ | ✅ |
| Cancellation propagation | ❌ | ✅ |
| Exception propagation | ❌ | ✅ |
| Hierarchy | ❌ | ✅ |
| Predictability | ❌ | ✅ |
| Maintainability | ❌ | ✅ |
How to apply for android api call
Lets discuss three Scenarios to understand better:
- Sequential / Dependent API calls (Chain)
- Parallel Independent API calls (Zip – need both results)
- Parallel Calls — Independent — Partial Failure Allowed
All examples are structured concurrency safe and lifecycle-aware.
Base Assumption (Clean Architecture Style)
Assume:
class FirstApiUseCase
class SecondApiUseCase
Each returns:
Flow<Result<T>>
And everything runs inside:
viewModelScope
Which means:
- Automatically cancelled when ViewModel is cleared
- No memory leaks
- Structured lifecycle control
Scenario 1: Sequential API Calls (Second Depends on First)
Call API-1 → based on result → call API-2
Example:
- Fetch user profile
- Use userId → fetch user transactions
Let implement this with flatMapLatest chain
fun loadUserData(userId: String) {
firstApiUseCase.execute(userId)
.onStart { showLoading() }
.flatMapLatest { userResult ->
if (userResult is Result.Success) {
secondApiUseCase.execute(userResult.data.id)
} else {
flowOf(Result.Error(Exception("User fetch failed")))
}
}
.onEach { transactionResult ->
handleTransactionResult(transactionResult)
}
.catch { showError(it) }
.onCompletion { hideLoading() }
.launchIn(viewModelScope)
}
Code Flow Explanation
- firstApiUseCase.execute() starts.
- When first API emits success →
- flatMapLatest triggers second API.
- If first fails → second never runs.
- Everything is inside viewModelScope.
- If ViewModel clears → both calls cancel.
Structured Concurrency Law Applied:
- Parent scope owns both API calls.
- Cancellation flows downward.
- Exceptions propagate upward.
- Execution remains bounded inside scope.
Scenario 2: Parallel Independent Calls (Zip – Need Both Results)
Call API-A and API-B in parallel
Continue only when BOTH succeed
Example:
- Fetch wallet balance
- Fetch recent transactions
They are independent but UI needs both.
Lets implement this with zip
fun loadDashboard() {
val balanceFlow = balanceUseCase.execute()
val transactionFlow = transactionUseCase.execute()
balanceFlow
.zip(transactionFlow) { balanceResult, transactionResult ->
Pair(balanceResult, transactionResult)
}
.onStart { showLoading() }
.onEach { (balance, transactions) ->
renderDashboard(balance, transactions)
}
.catch { showError(it) }
.onCompletion { hideLoading() }
.launchIn(viewModelScope)
}
Code Flow Explanation
- Both APIs start at the same time.
- zip waits for BOTH to emit.
- If either fails → whole flow fails.
- Structured and lifecycle-safe.
Why This Is Structured
Both flows:
- Are children of the same scope
- Are cancelled if ViewModel clears
- Fail together unless using supervisor
Scenario 3: Parallel Calls — Independent — Partial Failure Allowed
Example:
- Load Profile
- Load Notifications
- Even if notifications fail → still show profile
Lets implement this with Supervisor Scope
Assuming use case returns Flow:
fun loadHomeScreen() {
viewModelScope.launch {
supervisorScope {
val profileFlow = async {
profileUseCase.execute().catch { emit(null) }.firstOrNull()
}
val notificationFlow = async {
notificationUseCase.execute().catch { emit(null) }.firstOrNull()
}
val profile = profileFlow.await()
val notifications = notificationFlow.await()
profile?.let { renderProfile(it) }
notifications?.let { renderNotifications(it) }
}
}
}
What Happens Here (Code Flow)
- viewModelScope.launch → lifecycle owned by ViewModel
- supervisorScope creates structured boundary
- Two async blocks start in parallel
- If one throws → it does NOT cancel the other
- Both results are awaited safely
- Each result handled independently
This is still 100% structured concurrency.
Why Not Use coroutineScope?
If you use:
coroutineScope {
async { ... }
async { ... }
}
If one throws →
Parent gets cancelled →
Sibling gets cancelled automatically ❌
That’s Law #4: Exceptions propagate upward.
But in this case we WANT:
- Failure isolation
- Independent execution
So we use supervisorScope.
Structured Hierarchy Now Looks Like
viewModelScope
└── supervisorScope
├── async Profile
└── async Notifications
Rules here:
- Parent waits for both
- Failure does NOT cancel sibling
- Still lifecycle aware
- Still bounded
- Still structured
No GlobalScope.
No fire-and-forget.
Final Takeaway
Structured Concurrency is not just a feature. It’s a philosophy.
It says:
- Async work must have ownership.
- Work must be grouped logically.
- Lifecycle must be controlled.
- Errors must be visible.
- Cancellation must be predictable.
If concurrency is a tree —
Structured concurrency makes sure every branch belongs to something.
No floating jobs | No silent failures | No chaos
Top comments (0)