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 approachThe problem with it
Raw Threadyou manage thread creation, cancellation, and getting back to the main thread entirely by hand
AsyncTaskdeprecated since Android 11 — prone to leaking Activities/Fragments and mishandling configuration changes
Callback interfaceswork, 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:

DispatcherUsed for
Dispatchers.Mainupdating the UI — the only dispatcher allowed to touch Views
Dispatchers.IOnetwork calls and disk access (database queries, file reads/writes) — optimized for many blocked-waiting-on-I/O threads
Dispatchers.DefaultCPU-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.

Next up: Coroutines get you a single result back. Flow vs LiveData covers what happens when you need a continuous stream of updating results — like a trail list that refreshes itself whenever the underlying data changes.