androidengineers.Book a session

Threading & Concurrency

Kotlin Coroutines: Scopes, Contexts, Jobs

article30 minMedium

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.

DispatcherThread PoolUse For
Dispatchers.MainUI threadUI updates, ViewModel logic
Dispatchers.IO64 threads (scalable)Network, disk, database
Dispatchers.DefaultCPU-core countCPU-intensive: sorting, parsing, crypto
Dispatchers.UnconfinedNo specific threadRare; 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

ConceptWhat It Controls
CoroutineScopeLifetime boundary — cancel the scope, cancel all children
CoroutineContextSet of configuration elements (dispatcher, job, name)
JobLifecycle of one coroutine; forms parent-child tree
DeferredA Job with a return value; use await()
SupervisorJobChildren fail independently
Dispatchers.IONetwork and disk work
Dispatchers.DefaultCPU-intensive work
withContextSwitch dispatcher mid-coroutine without launching a new one
ensureActive()Make CPU-bound loops cancellation-aware

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Kotlin Coroutines: Scopes, Contexts, Jobs | Android System Design | Android Engineers