Coroutines Basics Explained

KOTLIN › Coroutines

A thread is an expensive resource: the operating system can only run so many of them at once, and one that's sitting idle waiting for a network response is a thread wasted. Coroutines exist to solve exactly that problem. A suspend function can pause its work and hand the thread back for other work to use, then pick up again later, possibly on a different thread. This is why interviewers push past the syntax and ask specifically whether you understand suspending versus blocking, because that difference is the entire point of coroutines.

The cleanest way to see it is delay next to Thread.sleep:

// Blocks the thread for the full second, nothing else can use it
launch { Thread.sleep(1000) }

// Suspends the coroutine; the thread is free during the wait
launch { delay(1000) }

Thread.sleep parks the thread itself, so if that thread is shared with other coroutines, they all stall too. delay is a suspend function: it pauses only the coroutine, releases the thread immediately, and schedules a resume once the time is up.

Because a suspend function needs to be able to pause partway through and resume later, the compiler can't compile it like an ordinary function. It applies a continuation-passing style transform: it adds a hidden Continuation parameter to the function and rewrites the body into a state machine, one step per suspension point. When execution hits a suspension point, the state machine records exactly where it stopped, and that record is what gets resumed later, on whatever thread the dispatcher picks.

suspend fun fetchUser(id: Int): User {
    val data = networkCall(id)   // suspension point, step 1
    return parse(data)           // step 2, resumes here
}

// Roughly compiles to something that carries a Continuation
// and a label marking which step to resume at

This machinery is also why a suspend function can only be called from two places: another suspend function, which already has a Continuation to pass along, or from inside a coroutine started by a builder like launch or async, which creates one. Calling delay from an ordinary function is a compile error, there's nowhere for the continuation to come from.

With suspending functions in place, you start coroutines with a **builder**. The two you'll reach for constantly are launch and async, and they differ in what you get back.

launch starts a coroutine for its side effects and returns a Job. There's no result to collect, it's fire-and-forget:

val job: Job = scope.launch {
    saveToDatabase(user)
}
// job is returned immediately; saveToDatabase keeps running independently

async starts a coroutine that computes a value and returns a Deferred<T>. You call await() on it to suspend until that value is ready:

val deferred: Deferred<User> = scope.async { api.getUser(id) }
val user = deferred.await()   // suspends, doesn't block, until the result lands

Both builders inherit the scope's dispatcher by default, and both let you override it per call: scope.launch(Dispatchers.IO) { ... } runs specifically on IO regardless of what the scope defaults to.

async earns its keep for parallel decomposition: start two things at once, then combine their results.

val a = scope.async { fetchScoreA() }
val b = scope.async { fetchScoreB() }
val total = a.await() + b.await()   // both ran concurrently, not one after the other

If you don't need a return value, launch is the simpler, correct tool.

launch and async both assume you're already inside a coroutine, but something has to start first. runBlocking is that bridge: it's a builder that blocks the calling thread until its body finishes, letting ordinary blocking code call into the coroutine world.

fun main() = runBlocking {
    val result = async { fetchData() }.await()
    println(result)
}

That makes it the right fit for exactly two places: a main() function, and tests, where blocking the thread while coroutines run is expected and fine. It's the wrong tool anywhere on Android's UI path. Calling runBlocking on the main thread freezes the UI for as long as the body takes, which defeats the entire reason you reached for coroutines in the first place.

There's a related builder worth knowing by name: coroutineScope { }. Unlike runBlocking, it's itself a suspend function, so it doesn't block a thread and doesn't switch context, it runs on the same dispatcher that called it, and suspends the calling coroutine until every child launched inside it finishes. It's how you group a batch of child coroutines and wait for all of them without leaving your own scope.

launch returns a Job, and a Job is best understood as **a handle on the lifetime of some running work**. Not the result, not the thread: the lifetime.

val job = scope.launch { syncData() }

job.isActive       // started and not finished or cancelled
job.isCompleted    // finished, whether normally or by cancellation
job.isCancelled    // cancellation was requested
job.cancel()       // request it stop
job.join()         // suspend until it is finished

Those three booleans describe six states, and knowing the transitions explains behaviour that otherwise looks contradictory:

| State | isActive | isCompleted | isCancelled | |---|---|---|---| | New (lazy, not started) | false | false | false | | Active | true | false | false | | Completing (waiting on children) | true | false | false | | Cancelling (unwinding) | false | false | true | | Cancelled | false | **true** | true | | Completed | false | true | false |

Two rows do the interview work. **Cancelling** is why cancel() returns immediately but the work has not stopped yet: it is a request, and the coroutine is still unwinding its finally blocks. **Completing** is why a parent can look busy after its own body has finished: it is waiting for its children before it can complete.

Note also that isCompleted is true for a cancelled job. A cancelled coroutine is finished, just not successfully, so testing isCompleted to mean "succeeded" is a bug.

join() and await() look similar and answer different questions. join() waits for **completion**; await() waits for a **result**.

val job: Job = scope.launch { sync() }
job.join()                    // returns Unit when finished

val deferred: Deferred<User> = scope.async { loadUser() }
val user: User = deferred.await()   // returns the value

Deferred<T> **is** a Job: it extends the interface and adds await(). So everything true of a job is true of a deferred, and you can cancel() or join() a deferred exactly as you would a job.

They also differ on failure. join() never throws the coroutine's exception, it simply returns when the coroutine is finished however it finished. await() rethrows it. That asymmetry is why the same failing coroutine can look silent or loud depending on which you called.

The mistake worth avoiding is using async for its shape rather than its purpose:

// Pointless: this is just a sequential call with extra allocation
val a = async { first() }.await()
val b = async { second() }.await()

// The reason async exists: both are in flight at once
val a = async { first() }
val b = async { second() }
a.await() + b.await()

Starting an async and awaiting it on the very next line runs sequentially. Concurrency comes from **starting both before awaiting either**.

Every coroutine you launch needs a CoroutineScope, and on Android you'll almost always use one handed to you, viewModelScope, lifecycleScope, rather than building your own. A scope bundles a CoroutineContext, dispatcher, Job, and more, and ties coroutines to a lifetime.

That lifetime relationship is what **structured concurrency** means: a coroutine launched inside a scope becomes that scope's child. Cancel the scope, or its Job, and the cancellation propagates down to every child automatically. You never hunt down and cancel each one individually.

val scope = CoroutineScope(Job())
scope.launch { delay(5000); println("A") }
scope.launch { delay(5000); println("B") }
scope.cancel()   // both children are cancelled immediately

This is also what stops coroutines from leaking: no orphaned background work outliving the screen, or the object, that started it.

A distinction that trips people up in interviews: **a suspend function is not a coroutine.**

suspend fun loadUser(id: String): User = api.fetch(id)

Nothing here starts anything. This is a function with an unusual calling convention: it can pause and give its thread back. It has no lifetime of its own, no Job, and nothing to cancel. It runs entirely inside whatever coroutine called it, on whatever dispatcher that coroutine is using, and it finishes before its caller carries on.

A coroutine is what a **builder** creates:

scope.launch { loadUser(id) }   // this is a coroutine: it has a Job

The practical consequence is a design rule. A suspend function should not create coroutines behind its caller's back, because it has nothing to bound their lifetime with:

// Bad: hidden scope, invisible to the caller, cancelled by nobody
suspend fun refresh() {
    CoroutineScope(Dispatchers.IO).launch { syncEverything() }
}

// Good: needs concurrency, so it borrows the caller's lifetime
suspend fun refresh() = coroutineScope {
    launch { syncUsers() }
    launch { syncPosts() }
}

coroutineScope { } is the right tool because it inherits the caller's job, so cancelling the caller cancels the work, and refresh() does not return until both children are finished. The caller sees one suspend function that does what it says.

That rule has a well-known counterexample people reach for by accident: GlobalScope.

GlobalScope.launch { uploadLogs() }   // lives until the process dies

GlobalScope is a scope with no job to speak of and no lifetime. Nothing cancels it, so every guarantee structured concurrency gives you is switched off: the work outlives the screen that started it, holds references to whatever it captured, and keeps running after the user has moved on. It is marked @DelicateCoroutinesApi for exactly this reason.

The uses people reach for it for each have a better answer:

- **"This should survive the screen."** Inject an application-scoped CoroutineScope instead, so it is still cancellable, still testable, and still visible in the dependency graph:

@Provides @Singleton
fun appScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

- **"This should survive the process."** That is WorkManager, not a coroutine. A coroutine of any kind dies when the process does. - **"I just need to call a suspend function from here."** Use the scope that already matches the lifetime: viewModelScope, lifecycleScope, or rememberCoroutineScope.

The only genuinely defensible uses are top-level application work that truly must not be cancelled, and main in a JVM command-line program. On Android, seeing GlobalScope in a code review is nearly always a bug.

Put it together and the pieces form one idea repeated at different scales.

A suspend function pauses instead of blocking, because the compiler rewrites it so it can remember where it stopped. It is a function, not a coroutine: it has no lifetime of its own and runs inside whoever called it.

A **builder** turns that into a coroutine with a lifetime. launch starts work you do not need a result from and hands you a Job; async starts work you do and hands you a Deferred, which is a Job that also carries a value. Start both before awaiting either, or you have written sequential code with extra steps.

Every coroutine belongs to a **scope**, which owns a Job that parents the coroutine's own. That parent link is what makes cancelling a scope cancel everything beneath it, and it is why viewModelScope and lifecycleScope exist rather than you constructing scopes by hand.

The line interviewers listen for: coroutines replace blocked threads with cheap, pausable units of work, and every primitive here exists to make that pausing **structured**, so that work is always owned by something that will eventually cancel it.

From here the neighbouring topics pick up each thread: which thread the work runs on, what happens when it fails, how cancellation actually propagates, and what the compiler is really doing underneath.

One last practical point: knowing when **not** to reach for a coroutine.

Coroutines shine when you need to suspend -- waiting for a network response, a database query, a file read, or a timer. But for work that's purely computational and doesn't need to wait for anything, a coroutine adds overhead without benefit:

// Unnecessary coroutine -- this is pure computation
suspend fun double(n: Int): Int = n * 2  // nothing to suspend on

// Just a regular function
fun double(n: Int): Int = n * 2

Similarly, don't launch a coroutine just to switch to the main thread when you're already on the main thread:

// Already on Main inside a click listener
button.setOnClickListener {
    // Don't do this:
    lifecycleScope.launch(Dispatchers.Main) {
        textView.text = "clicked"
    }
    // Just do this:
    textView.text = "clicked"
}

The rule of thumb: if nothing in the function body suspends, consider whether it needs to be a suspend fun at all. Mark it suspend only when it calls other suspending functions or when you want to participate in structured concurrency for cancellation. A function that does a quick in-memory transformation is better left as a plain function.

Back to Coroutines Basics