Coroutines Basics
This is one of the biggest topics in the entire track, so we're going to take it slowly and build up the full picture. Coroutines are Kotlin's answer to a problem every Android app faces: work like loading a list of trails from a database, fetching data over the network, or reading a file, all takes real time — and none of it can happen directly on Android's main thread without freezing the UI while it waits. Get this wrong and the system shows the user an "Application Not Responding" dialog. Coroutines let you write that asynchronous work in a style that reads like ordinary, top-to-bottom sequential code, while Kotlin handles the actual thread-juggling underneath.
The Problem: The Main Thread Must Never Block
Every Android app has one main (UI) thread responsible for drawing the screen and responding to touches. Any call that takes noticeable time — a network request, a Room database query, opening a file — has to run somewhere else, and then hand its result back to the main thread when it's done. Before coroutines, Android developers reached for a few older tools to manage this:
| Older approach | The problem with it |
|---|---|
Raw Thread | you manage thread creation, cancellation, and getting back to the main thread entirely by hand |
AsyncTask | deprecated since Android 11 — prone to leaking Activities/Fragments and mishandling configuration changes |
| Callback interfaces | work, but nest badly — loading trails, then their photos, then syncing them quickly turns into deeply indented "callback hell" |
suspend Functions: Asynchronous Code That Reads Like Sequential Code
A function marked suspend can pause its execution without blocking the thread it's
running on, and resume later exactly where it left off. From the caller's point of view, you just
call it and use its return value directly — no callback, no listener interface:
suspend fun loadTrails(): List<Trail> {
// This line awaits a Room database query, but never blocks the calling thread
val trails = trailDao.getAllTrails()
return trails
}
// Calling it from another suspend function — reads top to bottom, no callback nesting
suspend fun refreshScreen() {
val trails = loadTrails()
updateUi(trails)
}
Nothing about that code looks asynchronous — no onSuccess/onFailure
interface, no nested lambda — but under the hood, the suspend keyword lets the Kotlin
compiler generate the machinery to pause at trailDao.getAllTrails(), free up the
thread while waiting, and resume with the result once it's ready.
CoroutineScope and Structured Concurrency
A coroutine can never run on its own — it always runs inside a CoroutineScope, which
defines its lifetime. This is called structured concurrency, and it's a genuinely
important safety property: cancelling a scope automatically cancels every coroutine launched inside
it, so background work can never outlive the thing that started it and silently keep running (and
consuming resources, or worse, trying to update a screen that no longer exists).
In Android, you rarely create a raw CoroutineScope yourself — Jetpack provides
lifecycle-aware scopes that are already tied to something meaningful:
class TrailListViewModel(private val repository: TrailRepository) : ViewModel() {
fun loadTrails() {
// viewModelScope is tied to this ViewModel's lifetime
viewModelScope.launch {
val trails = repository.loadTrails()
// ...update LiveData/StateFlow with trails
}
}
}
viewModelScope is automatically cancelled the moment the ViewModel is
cleared (typically when its screen is finally destroyed) — so any in-flight coroutine it launched,
like an unfinished network call, is cancelled right along with it. No leaked background work, no
manual cleanup code required. We'll build on this ViewModel pattern directly in
ViewModel & LiveData.
launch vs. async/await
A scope gives you two main ways to start a coroutine, depending on whether you need a result back.
launch: Fire-and-Forget
launch starts a coroutine and returns a Job you can use to cancel it, but
it doesn't hand back a value — use it when you just want the work to happen:
viewModelScope.launch {
repository.deleteTrail(trailId) // we don't need a result back
}
async/await: Get a Result Back
async starts a coroutine and immediately returns a Deferred<T> — a
placeholder for a result that will exist later. Calling .await() on it suspends until
that result is ready:
viewModelScope.launch {
// Start both loads concurrently...
val trailsDeferred = async { repository.loadTrails() }
val statsDeferred = async { repository.loadStats() }
// ...then wait for both results, without blocking any thread
val trails = trailsDeferred.await()
val stats = statsDeferred.await()
}
async is what you reach for when two or more suspend calls don't depend on each other
and can genuinely run at the same time — launch covers everything else.
Dispatchers: Choosing Which Thread(s) to Run On
A Dispatcher decides what underlying thread (or thread pool) a coroutine actually runs
on:
| Dispatcher | Used for |
|---|---|
Dispatchers.Main | updating the UI — the only dispatcher allowed to touch Views |
Dispatchers.IO | network calls and disk access (database queries, file reads/writes) — optimized for many blocked-waiting-on-I/O threads |
Dispatchers.Default | CPU-intensive work — sorting a large list, parsing, complex calculations |
withContext(Dispatchers.IO) { } temporarily switches the current coroutine to a
different dispatcher for just the block inside it, then switches back automatically when the block
finishes — this is how a single suspend function moves work off the main thread and
back again without the caller needing to know or care:
suspend fun loadTrails(): List<Trail> = withContext(Dispatchers.IO) {
// This block runs on a background thread suited to disk/network work
trailDao.getAllTrails()
}
Full Worked Example: A ViewModel That Loads on IO, Updates the UI on Main
Putting all of this together — viewModelScope, launch, and
withContext(Dispatchers.IO) — here's a realistic TrailLog ViewModel function
that loads trails off the main thread and updates LiveData back on it, automatically:
class TrailListViewModel(private val repository: TrailRepository) : ViewModel() {
private val _trails = MutableLiveData<List<Trail>>()
val trails: LiveData<List<Trail>> = _trails
fun loadTrails() {
viewModelScope.launch {
// Suspends here — the IO work happens off the main thread
val result = withContext(Dispatchers.IO) {
repository.loadTrails()
}
// Execution resumes here back on Dispatchers.Main automatically —
// LiveData updates must happen on the main thread, and they do
_trails.value = result
}
}
}
Notice there's no explicit "switch back to main thread" step after withContext returns —
viewModelScope.launch itself runs on Dispatchers.Main by default, so
control simply resumes there the instant the IO block completes. That's the whole promise of
coroutines in one example: real background work, written as a straight line of code.