Coroutines are Kotlin's answer to asynchronous programming — lightweight, structured, and composable. If you can hold three concepts clearly in your head — Scope, Context, and Job — the rest of the coroutine API falls naturally into place.
The Three Pillars
CoroutineScope
└── CoroutineContext ← a set of key-value pairs
├── Job ← controls the lifecycle (start, cancel, complete)
├── Dispatcher ← controls *which thread* runs the coroutine
├── CoroutineName ← for debugging
└── ExceptionHandler ← handles uncaught exceptions
Every coroutine lives inside a scope. The scope carries a context. The context always includes a Job.
CoroutineScope
A scope defines the boundary of a group of coroutines. When the scope is cancelled, every coroutine launched inside it is cancelled — automatically, recursively.
// Creating a scope manually (usually for testing or custom components)
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
scope.launch { doWork() }
scope.cancel() // cancels all launched coroutines
// In a ViewModel — use the built-in scope
class FeedViewModel : ViewModel() {
init {
viewModelScope.launch { // cancelled when ViewModel.onCleared() is called
val posts = repository.fetchPosts()
_uiState.value = UiState.Success(posts)
}
}
}
// In a Fragment — tied to view lifecycle
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state -> render(state) }
}
}
Never use GlobalScope in production code. It has no lifecycle, cannot be cancelled, and makes testing impossible.
CoroutineContext
A CoroutineContext is an immutable set of elements. You compose contexts with +:
val context = Dispatchers.IO + CoroutineName("FetchUser") + SupervisorJob()
val scope = CoroutineScope(context)
Child coroutines inherit the parent's context, but can override specific elements:
scope.launch(Dispatchers.IO) { // override dispatcher for this child
val data = fetchFromNetwork() // runs on IO thread pool
withContext(Dispatchers.Main) { // switch context mid-coroutine
updateUI(data) // runs on main thread
}
}
Dispatchers
The Dispatcher decides which thread pool executes the coroutine.
| Dispatcher | Thread Pool | Use For |
|---|---|---|
Dispatchers.Main | UI thread | UI updates, ViewModel logic |
Dispatchers.IO | 64 threads (scalable) | Network, disk, database |
Dispatchers.Default | CPU-core count | CPU-intensive: sorting, parsing, crypto |
Dispatchers.Unconfined | No specific thread | Rare; avoid in production |
// Common pattern: start on Main, switch to IO, return to Main
viewModelScope.launch {
_uiState.value = UiState.Loading
val result = withContext(Dispatchers.IO) {
userRepository.getUser(userId) // runs on IO thread
}
_uiState.value = UiState.Success(result) // back on Main
}
withContext is not a coroutine launch — it's a suspension point that switches the context of the current coroutine. It doesn't create a new Job.
Job and the Lifecycle
Every coroutine is a Job. Jobs form a parent–child tree:
scope.Job
├── launch { A } ← child Job
│ └── launch { B } ← grandchild Job
└── launch { C } ← child Job
Cancellation propagates downward: cancelling the parent cancels all descendants.
Failure propagates upward: if a child fails with an unhandled exception, the parent (and all siblings) are cancelled too — unless you use SupervisorJob.
val job = viewModelScope.launch {
val result = async { slowOperation() }
println(result.await())
}
// Cancel from the outside
job.cancel()
// Check status
if (job.isActive) { ... }
if (job.isCancelled) { ... }
if (job.isCompleted) { ... }
Deferred: A Job That Returns a Value
async { } returns a Deferred<T> — a Job that produces a result. You get the result with await(), which suspends until the value is ready.
viewModelScope.launch {
// Launch both in parallel
val userDeferred = async(Dispatchers.IO) { userRepository.getUser() }
val postsDeferred = async(Dispatchers.IO) { feedRepository.getPosts() }
// Await both — total time = max(userTime, postsTime), not sum
val user = userDeferred.await()
val posts = postsDeferred.await()
_uiState.value = UiState.Success(user, posts)
}
SupervisorJob: Isolating Failures
With a regular Job, a failing child cancels the parent and all siblings. With SupervisorJob, each child is independent — a failure in one doesn't affect the others.
// ❌ Regular Job: if fetchUser() throws, fetchFeed() is also cancelled
val scope = CoroutineScope(Dispatchers.Main + Job())
// ✅ SupervisorJob: fetchUser() and fetchFeed() are independent
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
scope.launch { fetchUser() } // failure here doesn't cancel fetchFeed()
scope.launch { fetchFeed() }
viewModelScope uses a SupervisorJob internally — that's why a failed launch inside a ViewModel doesn't cancel the entire ViewModel scope.
Structured Concurrency
The key invariant: a coroutine cannot outlive its scope. This means:
- No coroutine is silently orphaned when the screen rotates
- No network call continues after the user has navigated away
- Cancellation is guaranteed — you don't have to remember to cancel manually
// This is safe — if the Fragment is destroyed, the network call is cancelled
viewLifecycleOwner.lifecycleScope.launch {
val user = withContext(Dispatchers.IO) { api.getUser() }
binding.nameText.text = user.name
}
Cancellation Is Cooperative
Cancellation doesn't kill a coroutine instantly — it sets a cancellation flag. The coroutine must check for cancellation to stop. Kotlin's built-in suspension points (delay, withContext, await) all check automatically.
viewModelScope.launch {
repeat(1_000) { i ->
ensureActive() // throws CancellationException if cancelled
processItem(items[i])
}
}
CPU-bound loops without suspension points are not cancellable unless you call ensureActive() or yield() periodically.
Practical Pattern: Safe API Calls
class UserRepository(
private val api: UserApi,
private val dao: UserDao,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
suspend fun getUser(id: String): Result<User> = withContext(ioDispatcher) {
try {
val remote = api.fetchUser(id)
dao.upsert(remote.toEntity())
Result.success(remote)
} catch (e: IOException) {
val cached = dao.getUser(id)
if (cached != null) Result.success(cached.toDomain())
else Result.failure(e)
}
}
}
Note: CancellationException should never be caught and swallowed — let it propagate so structured concurrency can function correctly.
Key Takeaways
| Concept | What It Controls |
|---|---|
CoroutineScope | Lifetime boundary — cancel the scope, cancel all children |
CoroutineContext | Set of configuration elements (dispatcher, job, name) |
Job | Lifecycle of one coroutine; forms parent-child tree |
Deferred | A Job with a return value; use await() |
SupervisorJob | Children fail independently |
Dispatchers.IO | Network and disk work |
Dispatchers.Default | CPU-intensive work |
withContext | Switch dispatcher mid-coroutine without launching a new one |
ensureActive() | Make CPU-bound loops cancellation-aware |