Coroutine scopes and dispatchers
A coroutine has to run somewhere — on some thread — and it has to belong to something that controls its lifetime. Those are the two ideas in this lesson: dispatchers decide which thread, and scopes decide the lifetime. Getting these right is what makes coroutines behave correctly on Android rather than leaking work or blocking the UI.
Dispatchers — which thread the work runs on
A dispatcher decides which thread (or pool of threads) a coroutine runs on. Kotlin gives you a few standard ones, each tuned for a kind of work:
launch(Dispatchers.Main) { ... } // the UI thread — for touching the screen
launch(Dispatchers.IO) { ... } // a large pool — for network, disk, database
launch(Dispatchers.Default) { ... } // a CPU-sized pool — for heavy computation
Dispatchers.Main— the single UI thread (on Android). The only place you may update the screen, and the place you must not do slow work. Short, UI-touching code goes here.Dispatchers.IO— a large pool of threads optimised for waiting work: network calls, reading files, database queries. These operations spend their time blocked on input/output, so it is fine to have many threads mostly idle. This is where the bulk of your background work goes.Dispatchers.Default— a pool sized to the number of CPU cores, for computation-heavy work: parsing a large response, sorting a big list, image processing. More threads than cores would not help, because the work is CPU-bound.
The rule of thumb: UI on Main, waiting on IO, computing on Default. Choosing the wrong one is a
real bug — heavy computation on Main freezes the UI; a thousand concurrent network calls on
Default starves your CPU work of threads.
Switching threads with withContext
The common pattern on Android: do slow work off the main thread, then come back to update the UI.
withContext switches the dispatcher for a block and switches back when it finishes:
suspend fun loadAndShow() {
val data = withContext(Dispatchers.IO) { // switch to a background thread
fetchFromNetwork() // slow work, off the main thread
} // switch back automatically
updateUI(data) // back on the calling thread (Main)
}
Read the shape: the slow fetchFromNetwork() runs on IO, and the moment withContext returns, you
are back on the thread you started on (the main thread), ready to update the UI. This is the standard
"background work then UI update" pattern, and it is beautifully clean — no callbacks, no manual thread
hopping, just a block that says "run this over here". withContext returns the block's value, so you
get the result directly.
Notice you generally do not sprinkle dispatchers on every launch. You write your suspend
functions to switch to the right dispatcher internally with withContext, so callers do not have
to think about threads. A well-written suspend fun fetchUser() does its own withContext(IO)
inside — callers just call it.
Scopes — the lifetime of coroutines
A coroutine scope is what a coroutine belongs to, and it controls the coroutine's lifetime. This is the idea that makes coroutines safe, and it is the setup for structured concurrency (the next lesson). Every coroutine is launched in a scope:
val scope = CoroutineScope(Dispatchers.Default)
scope.launch { doWork() } // this coroutine belongs to `scope`
scope.cancel() // cancelling the scope cancels every coroutine in it
Why does this matter? Because it solves the biggest problem with old-style asynchronous code: leaked work. Imagine a screen starts a network call, and the user navigates away before it finishes. Without scopes, that call keeps running, and when it completes it tries to update a screen that no longer exists — a crash, or wasted work, or a memory leak. With scopes, the screen's coroutines live in the screen's scope, and when the screen goes away, its scope is cancelled, and every coroutine it started stops automatically. A scope ties a coroutine's life to something meaningful, so work cannot outlive its purpose.
The scopes you will actually use on Android
You will rarely create a raw CoroutineScope by hand. Android provides scopes tied to the right
lifecycles, and using them is the correct default:
viewModelScope— tied to aViewModel; its coroutines are cancelled when theViewModelis cleared. This is where most of your app's coroutines live.lifecycleScope— tied to anActivityorFragmentlifecycle; cancelled when the screen is destroyed.
You will meet these in the Android course. The point to take now is the principle: a coroutine should always live in a scope tied to how long its work is relevant. Launching a coroutine in a lifecycle-aware scope means you never have to remember to cancel it — the framework does, when the work stops mattering.
GlobalScope — the one to avoid
There is a scope called GlobalScope whose coroutines live for the whole application and belong to
nothing. It is tempting because it is easy — GlobalScope.launch { } just works — but it is almost
always wrong, for the exact reason scopes exist: a GlobalScope coroutine is not tied to any
lifecycle, so it does not get cancelled when the screen or task it was for goes away. It leaks. It is
the coroutine equivalent of a var you never clean up. Treat GlobalScope as a code smell: if you
see it, ask what scope the work should have belonged to. There is almost always a better answer.
Check your work
What a dispatcher decides. Which thread (or thread pool) a coroutine runs on.
The three standard dispatchers and their uses. Main (UI, no slow work), IO (network, disk,
database — waiting work), Default (CPU-heavy computation).
What withContext does. Switches the dispatcher for a block and switches back, returning the
block's value — the "background work then UI update" pattern.
Where you put the dispatcher switch. Inside your suspend functions, so callers need not think about threads.
What a coroutine scope controls. The lifetime of the coroutines launched in it.
The problem scopes solve. Leaked work — coroutines outliving the screen or task they were for.
What cancelling a scope does. Cancels every coroutine launched in it.
The two Android lifecycle scopes. viewModelScope and lifecycleScope, tied to the ViewModel
and the screen.
Why GlobalScope is a smell. Its coroutines are tied to nothing, so they are never cancelled and
they leak.
Practice
- Launch coroutines on
Dispatchers.Default,IO, andMain(inrunBlocking, which provides a main-like context) and print the thread name (Thread.currentThread().name) from each. Observe they differ. - Write a suspend function that does slow work in
withContext(Dispatchers.Default)and returns a result. Confirm the result comes back on the calling thread. - Reason about which dispatcher each of these needs: a network call, sorting a million-item list, updating a text label, reading a file.
- Create a
CoroutineScope, launch several coroutines in it, thencancel()the scope and confirm the coroutines stop. - Explain, in two sentences, why a network call launched in a screen's scope is safe when the user
navigates away, but the same call in
GlobalScopeis not. - Find (or imagine) a
GlobalScope.launchand rewrite it to belong to a meaningful scope.
Official documentation
- Kotlin — Coroutine context and dispatchers — The dispatchers and
withContext. - Kotlin — Coroutine scope — Scopes and lifetime.
- Android — Coroutines and lifecycle scopes —
viewModelScopeandlifecycleScope. - Kotlin — Why not GlobalScope — The leak problem.
Next: structured concurrency and cancellation — the feature that makes coroutines safe.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship