androidinterview.com

Kotlin Coroutines Interview Questions

38 questions

Tier
Difficulty
Level

Showing all 38 questions

Fundamentals & Suspension

Start here. Almost every coroutines interview opens with one of these.

What is the difference between suspending and blocking in Kotlin Coroutines?

Tier: EssentialDifficulty: Easy

Blocking ties up a thread until an operation finishes, the thread can't do anything else in the meantime. Suspending pauses a coroutine and gives the thread back, so that thread can go run other work, then the coroutine resumes later, possibly on a different thread.

fun blockingWait() {
    Thread.sleep(1000) // thread is stuck here, nothing else runs on it
}

suspend fun suspendingWait() {
    delay(1000) // coroutine pauses, thread is free to do other work
}

Thread.sleep() blocks. delay() suspends. Both wait a second, but Thread.sleep() freezes whatever thread called it, while delay() releases the thread and only reserves it again when the coroutine actually needs to resume.

On Android this is the whole reason coroutines are worth using. Blocking the main thread for too long freezes the UI and can trigger an ANR. Suspending on the main thread with something like delay() or a suspending network call doesn't block it at all, the UI stays responsive because the thread was never held hostage in the first place.

What is a suspend function in Kotlin Coroutines?

Tier: EssentialDifficulty: Medium

A suspend function is a function that can pause partway through its work without blocking the thread it's running on, and pick back up later exactly where it left off. You mark it with the suspend keyword and the compiler handles the rest.

suspend fun fetchUser(id: String): User {
    return api.getUser(id) // pauses here, thread is free while it waits
}

Under the hood, the compiler rewrites a suspend function into a state machine and adds a hidden Continuation parameter to it. When execution hits a suspension point, like a network call, the function saves its state and gives the thread back instead of sitting there blocked. Once the result is ready, the coroutine resumes from that exact point using the continuation, on whichever thread the dispatcher hands it.

A suspend function can only be called from another suspend function or from inside a coroutine builder like launch or async. That's the compiler protecting you. It stops you from calling a suspending network request straight from a button click listener with no coroutine backing it, which is exactly the kind of call that would otherwise crash or silently do nothing.

What is the difference between a Coroutine and a Java Thread?

Tier: EssentialDifficulty: Medium

A Java Thread is a real operating system thread, the OS schedules it directly, and it's expensive, each one reserves around a megabyte of stack memory and creating too many of them will crash or badly slow down your app. A coroutine is a much lighter unit of work that Kotlin schedules itself, on top of a small pool of real threads, so you can run tens of thousands of coroutines on just a handful of actual OS threads.

// thousands of these would exhaust threads and crash the app
repeat(100_000) { Thread { doWork() }.start() }

// this runs fine, coroutines are cheap
repeat(100_000) {
    scope.launch { doWork() }
}

The other big difference is suspension. A Thread has no concept of pausing itself and freeing its underlying resource, once it's blocked on something like a network call, it just sits there doing nothing until that call returns. A coroutine can suspend at a call like that, hand the thread back to the pool so other coroutines can use it, and resume later without ever needing a dedicated thread of its own for the whole wait.

That's also why coroutines are described as cooperative rather than preemptive. A Thread gets scheduled by the OS whether it wants it or not, but a coroutine only yields control at defined suspension points, which is cheap to set up and cheap to tear down compared to spinning up a new Thread for every unit of work.

What are Coroutines in Kotlin?

Tier: CommonDifficulty: Easy

A coroutine is a lightweight unit of concurrent work in Kotlin that can suspend and resume without blocking the underlying thread. The name comes from cooperative routines, functions that cooperate by pausing at defined points instead of running to completion or blocking the thread they sit on.

Coroutines are managed by the Kotlin runtime rather than the operating system, sitting on top of a small pool of real threads. That's what lets you launch tens of thousands of them without the memory and scheduling cost a real Thread would carry.

fun main() = runBlocking {
    launch {
        delay(1000)
        println("World")
    }
    println("Hello")
}

You start a coroutine with a builder like launch or async, inside a CoroutineScope. The scope ties the coroutine's lifetime to something concrete, an Activity, a ViewModel, or a parent coroutine, so it gets cancelled automatically instead of leaking. That tie between scope and lifetime is what Kotlin calls structured concurrency, and it's the main reason coroutines replaced raw threads and callbacks in Android code.

What is runBlocking in Coroutines?

Tier: CommonDifficulty: Easy

runBlocking starts a coroutine and blocks the calling thread until that coroutine, and everything inside it, finishes.

fun main() = runBlocking {
    println("start")
    delay(1000)
    println("end")
}

It's the bridge between ordinary blocking code and the coroutine world, useful in a main() function or a unit test, where you genuinely want to wait for the result before moving on. Anything you call inside its block can use delay, launch, or async normally, since it creates a real CoroutineScope.

The thing to watch for is using it anywhere in production Android code. Since it blocks the calling thread, running it on the main thread freezes the UI until the block completes, and running it inside another coroutine defeats the purpose of coroutines being non-blocking in the first place. runBlocking exists for tests and entry points, not for everyday coroutine code.

What is the difference between Thread.sleep() and delay() in Kotlin?

Tier: CommonDifficulty: Easy

Thread.sleep() blocks the actual OS thread it runs on, so nothing else can use that thread until the sleep finishes, while delay() suspends the coroutine and frees the thread for other work in the meantime.

// blocks the thread for 1 second, nothing else can run on it
Thread.sleep(1000)

// suspends the coroutine for 1 second, the thread is free to run other coroutines
delay(1000)

delay is itself a suspend function, so it can only be called from a coroutine or another suspend function. Calling Thread.sleep() inside a coroutine is a common mistake, it defeats the whole point, since it blocks the real thread that dispatcher's other coroutines were relying on, including on Dispatchers.Main, where it would also freeze the UI. Use delay for any wait inside a coroutine, and reserve Thread.sleep() for plain, non-coroutine code.

What is the difference between runBlocking and coroutineScope?

Tier: CommonDifficulty: Medium

runBlocking blocks the actual calling thread until its coroutine finishes, and can be called from regular non-suspend code, while coroutineScope suspends without blocking any thread, and can only be called from inside a suspend function.

  • runBlocking is a bridge, meant for main() functions and tests, where you need to call suspend code from a regular function and are fine waiting for the result.
  • coroutineScope is meant for structuring concurrent work inside code that's already suspending, letting you launch several children and wait for all of them without blocking the thread underneath.
  • Both give you structured concurrency, the block doesn't complete until every coroutine launched inside it completes.
  • Their exception handling differs a little too. An exception inside runBlocking propagates straight to its caller. An exception inside coroutineScope cancels every sibling coroutine first, then rethrows once they've all wound down.
fun main() = runBlocking {          // blocks main thread
    processUser(id = 1)
}

suspend fun processUser(id: Int) {
    coroutineScope {                 // does not block
        val profile = async { fetchProfile(id) }
        val posts = async { fetchPosts(id) }
        println("${profile.await()} ${posts.await()}")
    }
}

Why are Coroutines lightweight?

Tier: CommonDifficulty: Medium

Coroutines are lightweight because they don't need a dedicated OS thread each, the Kotlin runtime schedules them itself on top of a small shared pool of real threads.

A real Thread reserves its own stack, usually around a megabyte, and the OS has to context switch between them, so a system realistically tops out at a few thousand threads before things get slow. A coroutine has no thread of its own to reserve. It's just an object the runtime keeps track of, and when it suspends at a call like delay or a network request, it hands its thread back to the pool for other coroutines to use.

That's the core trick. Suspension frees the thread instead of blocking it, so the number of coroutines you can have alive at once has nothing to do with the number of real threads. You can run 100,000 coroutines on 4 threads without any of them fighting each other the way 100,000 real Threads would.

Creating and switching a coroutine is also cheap compared to a Thread, since it's cooperative rather than preemptive, the runtime only does work at suspension points instead of the OS interrupting and restoring full thread state on every switch.

How does suspension work internally (Continuation-Passing Style and state machines)?

Tier: CommonDifficulty: Hard

Under the hood, the Kotlin compiler rewrites every suspend function into a state machine, and adds a hidden parameter called a Continuation that carries the rest of the function to run once a suspension point completes.

A function like this,

suspend fun example() {
    println("Before")
    delay(1000)
    println("After")
}

is compiled into something closer to a function that takes a Continuation, tracks a label for which step it's on, and returns early whenever it hits a suspension point like delay.

fun example(cont: Continuation<Unit>): Any {
    val sm = cont as? ExampleStateMachine ?: ExampleStateMachine(cont)
    when (sm.label) {
        0 -> {
            println("Before")
            sm.label = 1
            return delay(1000, sm) // suspends here, returns control to the caller
        }
        1 -> {
            println("After")
            return Unit
        }
    }
}

This pattern is Continuation-Passing Style. The function's remaining work is packaged into that Continuation object and passed along, instead of the call stack just sitting there waiting for a return value. When delay finishes, it calls sm.resume(), which re-enters the same function at label 1 and picks up exactly where it left off.

Because the paused state is just a small object sitting on the heap, not a suspended thread holding onto a full stack, suspending costs almost nothing. This state machine plus Continuation pair is the mechanism everything else in coroutines builds on, cancellation, dispatcher switching, and structured concurrency all ride on top of it.

Scopes, Jobs & Structured Concurrency

What is the difference between launch and async in Kotlin Coroutines?

Tier: EssentialDifficulty: Easy

launch starts a coroutine and returns a Job, for work you don't need a result back from. async starts a coroutine and returns a Deferred<T>, for work where you do, and you get the value with .await().

launch {
    repository.logEvent("screen_view") // fire and forget
}

val userDeferred = async {
    repository.getUser(id) // need the result
}
val user = userDeferred.await()

They also differ in how an unhandled exception behaves. A launch coroutine propagates its exception up immediately, and if nothing catches it, it can crash the app. An async coroutine's exception is stored on the Deferred and only surfaces when you call .await(), so it's easy to accidentally lose it if you never call .await() on a standalone async. The simple rule, use launch for side effect work, use async only when you genuinely need the return value.

Which coroutine scopes are used in Android (lifecycleScope, viewModelScope, GlobalScope)?

Tier: EssentialDifficulty: Easy

Android gives you two scopes that are already wired to a lifecycle, lifecycleScope and viewModelScope, and one scope you should almost never reach for, GlobalScope.

  • lifecycleScope belongs to an Activity or Fragment. It's cancelled automatically when that Lifecycle reaches DESTROYED, so anything you launch in onCreate stops cleanly if the screen is destroyed mid work.
  • viewModelScope belongs to a ViewModel. It's cancelled when onCleared() runs, which survives configuration changes like rotation, so it's the right home for work that should keep running across a screen rotation but stop once the ViewModel itself is gone.
  • GlobalScope isn't tied to any lifecycle at all, it lives as long as the app process does. Anything launched in it keeps running even after the screen that started it is gone, which is exactly the leak structured concurrency is supposed to prevent.

The rule of thumb is simple, prefer viewModelScope or lifecycleScope since they clean up after themselves, and treat GlobalScope as a red flag in code review unless there's a genuinely process-lifetime task behind it, like a long-running background sync.

What is the difference between coroutineScope and supervisorScope?

Tier: EssentialDifficulty: Medium

coroutineScope cancels every other child the moment one of them fails, it's all or nothing. supervisorScope lets each child fail independently without touching its siblings.

coroutineScope against supervisorScopeTwo job trees, each a parent with three children, and in both trees child 2 throws. In the coroutineScope tree the failure travels up to the parent, the parent cancels child 1 and child 3, and everything ends. In the supervisorScope tree the failure stops before it reaches the parent, so child 1 and child 3 keep running and the scope stays alive.
coroutineScope compared with supervisorScope
The same failure under both scopes. Inside coroutineScope the exception travels up, cancels the parent, and takes the siblings with it. Inside supervisorScope the failure stops at the supervisor, so the siblings and the scope keep running.
// one failing call kills both
coroutineScope {
    val a = async { fetchA() }
    val b = async { fetchB() } // cancelled if fetchA() throws
    a.await() to b.await()
}

// one failing call doesn't affect the other
supervisorScope {
    val a = async { runCatching { fetchA() } }
    val b = async { runCatching { fetchB() } } // keeps running even if fetchA() throws
    a.await() to b.await()
}

Reach for coroutineScope when the result only makes sense if every piece succeeded, like combining several fields into one screen where a partial result is useless. Reach for supervisorScope when the calls are independent and a partial result is still useful, like a dashboard where one failed widget shouldn't blank out the other three.

It's worth not confusing supervisorScope with SupervisorJob(). supervisorScope { } is a suspend function you call from inside existing coroutine code, scoped to that one block. SupervisorJob() is a Job implementation you pass into a CoroutineContext when building a longer lived scope, like CoroutineScope(SupervisorJob() + Dispatchers.Main), which is exactly what viewModelScope does internally. Both give siblings the same independence from each other's failures, supervisorScope is for one block, SupervisorJob is for a scope that outlives any single function call.

Read more Coroutine exceptions handling (opens in a new tab)

What is a Job in Coroutines?

Tier: CommonDifficulty: Easy

A Job is a handle to a coroutine, it represents that coroutine's lifecycle and lets you cancel it, wait for it, or check its state.

Every coroutine builder returns one, launch returns a Job directly, and async returns a Deferred, which is a Job that also carries a result.

A Job moves through a few states as the coroutine runs.

  • New, created but not yet started, only reachable if you pass start = CoroutineStart.LAZY.
  • Active, running, the normal state right after creation.
  • Completing, finishing up, waiting on its children to complete.
  • Completed, fully done.
  • Cancelling, cancellation was requested and it's winding down.
  • Cancelled, fully stopped.
val job = viewModelScope.launch {
    repository.syncData()
}
job.cancel()      // request cancellation
job.join()        // suspend until it's actually done

Jobs also form a parent child hierarchy, a Job created inside another coroutine automatically becomes a child of that coroutine's Job. That hierarchy is the mechanism structured concurrency and cancellation propagation are both built on.

How do you combine multiple coroutine results?

Tier: CommonDifficulty: Medium

Launch each piece of work with async, then combine the results once every Deferred has completed, usually with .await() on each one directly, or awaitAll() for a list of them.

suspend fun loadProfile(id: Int): Profile = coroutineScope {
    val userDeferred = async { api.getUser(id) }
    val postsDeferred = async { api.getPosts(id) }
    val friendsDeferred = async { api.getFriends(id) }

    Profile(
        user = userDeferred.await(),
        posts = postsDeferred.await(),
        friends = friendsDeferred.await()
    )
}

Each async starts immediately, so all three calls run at the same time instead of one after another, and coroutineScope waits for all of them, combining is really just reading each Deferred's value once they're all ready.

For a list of same typed results, awaitAll() is a shorter way to write the same thing.

val allPosts = listOf(1, 2, 3)
    .map { id -> async { api.getPosts(id) } }
    .awaitAll()
    .flatten()

How do you run coroutines in series and in parallel?

Tier: CommonDifficulty: Medium

Running coroutines in series means calling one suspend function after another, so each waits for the previous one to finish before it starts. Running them in parallel means wrapping each one in async first, so they all start together.

// series, second call doesn't start until the first returns
suspend fun runInSeries() {
    val a = fetchA() // waits here
    val b = fetchB() // only starts after fetchA() finishes
}

// parallel, both start immediately
suspend fun runInParallel() = coroutineScope {
    val aDeferred = async { fetchA() }
    val bDeferred = async { fetchB() }
    val a = aDeferred.await()
    val b = bDeferred.await()
}

The difference comes down to when the second call actually begins. Calling a suspend function directly suspends the caller until it returns, so the next line genuinely waits. Wrapping it in async instead starts it right away and hands you a Deferred, so both calls are already running concurrently by the time you reach the first .await(). Reach for series when one call genuinely depends on the other's result, and parallel whenever they're independent, since there's no reason to pay for their combined latency when you don't have to.

What are suspendCoroutine and suspendCancellableCoroutine?

Tier: CommonDifficulty: Medium

Both wrap a callback based API to make it usable as a suspend function, giving you a Continuation to call once the callback fires. suspendCancellableCoroutine additionally hooks into cancellation, suspendCoroutine does not.

suspend fun getLocation(): Location = suspendCancellableCoroutine { cont ->
    val callback = LocationCallback { location ->
        cont.resume(location)
    }
    locationClient.request(callback)

    cont.invokeOnCancellation {
        locationClient.remove(callback) // cleanup if the coroutine is cancelled
    }
}

You call continuation.resume(value) when the callback succeeds, and continuation.resumeWithException(error) when it fails, that's the whole bridge from callback style to suspend style.

The difference is what happens if the coroutine gets cancelled while it's waiting. suspendCoroutine has no idea, the underlying call keeps running to completion even though nothing is listening for its result anymore. suspendCancellableCoroutine exposes invokeOnCancellation, so you can cancel or clean up the real underlying operation, and it also throws CancellationException itself the moment the coroutine is cancelled, instead of waiting for the callback to eventually fire. In practice, reach for suspendCancellableCoroutine by default, and only drop to suspendCoroutine when the API you're wrapping genuinely can't be cancelled.

What is viewModelScope and how does it work internally?

Tier: CommonDifficulty: Medium

viewModelScope is a CoroutineScope extension property on ViewModel that's automatically cancelled when the ViewModel's onCleared() runs, so anything you launch in it stops when the screen that owns it is really gone.

Internally it's backed by a SupervisorJob combined with Dispatchers.Main.immediate. The SupervisorJob means one failed child coroutine doesn't cancel the others sharing the scope. Main.immediate means code launched from the main thread runs right away instead of waiting a full loop of the message queue.

The lifecycle wiring happens through ViewModel's internal tag map. The first time you access viewModelScope, the ViewModel creates the scope and stores it as a Closeable tagged onto itself. ViewModel.onCleared() closes every tagged Closeable, and closing that one calls cancel() on the scope's Job. You never call that yourself, the framework does it the moment the ViewModel is actually destroyed, not on a configuration change.

class UserViewModel : ViewModel() {
    fun loadUser(id: Int) {
        viewModelScope.launch {
            val user = repository.getUser(id) // cancelled automatically in onCleared()
        }
    }
}

What is a CoroutineScope and what are the different Coroutine Scopes?

Tier: CommonDifficulty: Medium

A CoroutineScope is an object that ties a group of coroutines to a lifetime. It bundles a Job and a CoroutineContext together so cancelling the scope cancels every coroutine launched inside it.

Every coroutine builder, launch and async included, needs a scope to run in, there's no such thing as a coroutine that exists outside of one. That's what enforces structured concurrency, a child can't outlive the scope that created it.

There are a few different kinds of scope you'll run into.

  • Built in Android scopes like lifecycleScope and viewModelScope, already wired to a component's lifecycle and cancelled automatically.
  • GlobalScope, which isn't tied to anything and lives for the whole process, generally best avoided since nothing cancels it for you.
  • The suspend function builders coroutineScope { } and supervisorScope { }, which create a scope inline for structuring child coroutines within a single suspend function.
  • A custom scope you build yourself, typically CoroutineScope(SupervisorJob() + Dispatchers.Main), for a class that needs its own coroutine lifetime, like a repository or a presenter that isn't a ViewModel.

Which one you reach for depends on what already owns the lifetime you want. An Android component uses the scope Android gives you, and a plain class builds its own.

What is CoroutineContext in Kotlin?

Tier: CommonDifficulty: Medium

CoroutineContext is the set of elements that define how and where a coroutine runs, a small collection built up out of things like a dispatcher, a job, and an exception handler.

  • Job, tracks the coroutine's lifecycle and lets you cancel it.
  • A Dispatcher, like Dispatchers.IO or Dispatchers.Main, decides which thread it runs on.
  • CoroutineName, an optional label that shows up in debugging and thread dumps.
  • CoroutineExceptionHandler, handles an exception that goes uncaught.
val context = Dispatchers.IO + Job() + CoroutineName("sync") + handler
scope.launch(context) { repository.sync() }

Each of these is a CoroutineContext.Element, and the + operator combines them into a single context, later elements override earlier ones of the same type. A coroutine always has a context, when you don't specify pieces yourself, it inherits the rest from its parent scope, which is also how cancellation and dispatcher choices propagate down through a coroutine hierarchy by default.

What is the difference between withContext and async-await?

Tier: CommonDifficulty: Medium

withContext suspends the current coroutine, runs a block on a different dispatcher, and returns its result directly, it doesn't start a new concurrent coroutine, everything still happens sequentially from the caller's point of view. async starts a genuinely new child coroutine that runs concurrently, giving you back a Deferred you call .await() on whenever you actually need the result.

// sequential, just moved to a different dispatcher
val user = withContext(Dispatchers.IO) { db.getUser(id) }

// concurrent, both run at the same time
val userDeferred = async(Dispatchers.IO) { db.getUser(id) }
val postsDeferred = async(Dispatchers.IO) { db.getPosts(id) }
val result = userDeferred.await() to postsDeferred.await()

Use withContext when you just need to move a piece of work onto a different dispatcher and nothing else is happening at the same time. Reach for async and await when you actually want two or more things running in parallel, one async call alone with an immediate await() right after it gives you no concurrency benefit over withContext, it just adds overhead.

What is the meaning of structured concurrency in Kotlin Coroutines?

Tier: CommonDifficulty: Medium

Structured concurrency means every coroutine is launched inside a scope tied to a parent, so a coroutine can never outlive the scope that created it, and the parent doesn't consider itself finished until all its children are.

Structured concurrency job treeA parent job with three children, drawn twice. In the first tree the parent is cancelled, and the cancellation travels down every edge, so all three children are cancelled with it. In the second tree child 2 throws. The failure travels up to the parent, and the parent then cancels child 1 and child 3, so the whole tree ends together.
the structured concurrency job tree
Structured concurrency is a two way contract. Cancelling the parent cancels every child recursively, and one child failing cancels the parent, which then cancels the remaining children. Nothing in the tree can outlive it or fail silently.

This is the opposite of firing off a background thread or a GlobalScope coroutine and hoping it finishes before something else moves on. With structured concurrency, cancelling a parent's Job cancels every child recursively, and a failure in a child propagates back up to the parent, so errors can't just vanish silently in some detached background task.

suspend fun loadDashboard() = coroutineScope {
    val profile = async { fetchProfile() }
    val feed = async { fetchFeed() }
    // if either fails, the other is cancelled automatically
    Dashboard(profile.await(), feed.await())
}

The practical payoff is that you stop having to manually track and cancel coroutines yourself. Cancel the scope, or let it finish, and every coroutine it owns is accounted for, none of them are still running somewhere after the code that started them has moved on.

Read more Coroutines basics (opens in a new tab)

Exception Handling & Cancellation

This is where an interview separates people who have shipped coroutines from people who have read about them.

How do timeouts work in Kotlin Coroutines?

Tier: CommonDifficulty: Easy

withTimeout() runs a block and cancels it if it doesn't finish within the given time, throwing a TimeoutCancellationException, which is itself a subclass of CancellationException.

suspend fun fetchWithLimit(): User {
    return withTimeout(3000) {
        api.fetchUser() // cancelled if this hasn't returned in 3 seconds
    }
}

Because it throws a CancellationException under the hood, it uses the exact same cooperative cancellation machinery as calling .cancel() on a Job, the block only actually stops at its next suspension point. withTimeoutOrNull() does the same thing but returns null on timeout instead of throwing, which is usually more convenient since you don't need a try and catch just to fall back to a default value.

val user = withTimeoutOrNull(3000) { api.fetchUser() } ?: User.empty()

How does exception handling work in Kotlin Coroutines?

Tier: CommonDifficulty: Medium

A failure in a coroutine follows its Job hierarchy, it cancels the parent and any sibling coroutines by default, and where you're able to catch it depends on whether the failing coroutine was started with launch or async.

  • launch, an exception propagates up to the parent immediately, cancelling siblings along the way, and if it reaches the top of the hierarchy uncaught, it goes to a CoroutineExceptionHandler if one is installed, or crashes the app if not.
  • async, the exception is stored on the Deferred and only rethrown when you call .await(). It still cancels the parent and siblings right away though, the deferral is only in where you see it surface, not in whether it propagates through the hierarchy.
  • CoroutineExceptionHandler only fires for an exception that's truly uncaught, so it needs to sit on a coroutine that isn't going to hand its failure to a parent. That's normally the outermost coroutine in a hierarchy, a handler further down does nothing there since the failure escalates straight past it. A direct child of supervisorScope is the one exception to that, its failure never escalates at all, so its own handler is exactly where you'd put one, covered in a separate question. A handler on async never helps regardless of where it sits, since async always expects you to consume the failure through .await() instead.
  • CancellationException is treated differently from every other exception, structured concurrency swallows it as a normal, expected part of cancelling, it never reaches a CoroutineExceptionHandler and never crashes the app.
val handler = CoroutineExceptionHandler { _, e -> log(e) }
viewModelScope.launch(handler) {
    launch { throw IllegalStateException("boom") } // caught by handler above
}

A regular try and catch around a suspending call still works exactly as you'd expect. The one thing to watch is catching too broadly. A catch (e: Exception) also catches CancellationException, and if you don't rethrow it, the coroutine looks like it recovered from an error when it should actually have stopped.

What happens if you call .cancel() on a coroutine scope?

Tier: CommonDifficulty: Medium

Calling .cancel() on a CoroutineScope cancels that scope's root Job, and the cancellation cascades to every coroutine currently running inside it. The scope can never launch a new coroutine again afterward.

Cancellation is cooperative though, it doesn't forcibly stop code mid line the way killing a thread would. What actually happens is that every child's Job is marked cancelled, and the next time that coroutine hits a suspension point, like delay, a network call, or yield(), it throws a CancellationException there instead of resuming normally. Code that never suspends and never checks, a tight CPU loop with no suspend calls in it, keeps running until it eventually hits one, or finishes on its own. You can force a check yourself with ensureActive(), which throws immediately if the Job is already cancelled, without needing a real suspension point.

viewModelScope.launch {
    repeat(1000) { i ->
        ensureActive() // throws CancellationException if the job is cancelled
        heavyComputation(i)
    }
}

One gotcha worth knowing, CancellationException is a normal exception, so a broad try and catch (e: Exception) around suspending code catches it too, and unless you rethrow it, the coroutine looks like it just handled an error and keeps going instead of actually stopping. If you need to run cleanup after cancellation, like closing a socket with a suspend call, wrap that specific cleanup in withContext(NonCancellable) { }, since ordinary suspending code inside an already cancelled coroutine throws immediately otherwise.

What is the difference between job.cancel() and scope.cancel() in Coroutines?

Tier: CommonDifficulty: Medium

job.cancel() cancels one specific coroutine and its children, while scope.cancel() cancels the scope's own root Job, which takes down everything currently running in that scope and stops it from ever launching anything again.

val job = viewModelScope.launch { repository.sync() }
job.cancel()                 // only this coroutine and its children stop
viewModelScope.launch { }    // still works fine afterward

viewModelScope.cancel()      // the whole scope's Job is cancelled
viewModelScope.launch { }    // throws, this scope is dead now

That's why job.cancel() is the one you reach for day to day, cancelling one download or one search request without touching anything else sharing the scope. scope.cancel() is closer to a shutdown, it's what viewModelScope calls internally exactly once, in onCleared(), and calling it yourself anywhere else usually means the scope was the wrong thing to cancel, since it burns the scope for any future work too.

What happens if an exception is thrown inside an async coroutine but await() is never called?

Tier: CommonDifficulty: Hard

It depends on whether that async has a structured parent or not. If it's a child inside a normal scope, the exception still propagates up immediately and cancels its siblings, whether or not you ever call .await(). It only stays silently trapped when the async has no parent to escalate to, which mainly happens with a root level async like one started directly on GlobalScope.

// structured, exception propagates immediately, cancels the sibling too
viewModelScope.launch {
    val a = async { throw IllegalStateException("boom") }
    val b = async { fetchB() } // gets cancelled even though a.await() is never called
}

// unstructured, exception is trapped silently until await() is called
val leaked = GlobalScope.async {
    throw IllegalStateException("boom")
}
// nothing happens here, the exception just sits inside `leaked` forever unless you call leaked.await()

This is a common misconception, people assume any async failure is silently swallowed until await(). Inside structured concurrency, it isn't, the parent's Job gets cancelled the instant the child fails, the same as it would with launch, so the failure is visible through cancellation even if you never touch the Deferred directly. The genuinely dangerous case is an async with no structured parent to report to, that's the pattern to watch for in review, an async on GlobalScope, or any Deferred you create and then never store or await, can hide a real crash indefinitely.

Why is a CoroutineExceptionHandler installed on a child of supervisorScope ignored?

Tier: CommonDifficulty: Hard

Usually it isn't, that's the common misconception. A CoroutineExceptionHandler passed directly to a child launch() inside supervisorScope does get invoked when that child fails, supervisorScope treats its direct children like root coroutines for exception purposes. The handler that actually gets ignored is one installed on the coroutine that creates the supervisorScope, since a failing child never propagates its exception up to that parent in the first place.

Exception handler paths around supervisorScopeA parent coroutine opens a supervisorScope. Inside the scope a child launch carries its own CoroutineExceptionHandler and throws. The exception is handled at that child, its handler fires, because supervisorScope treats direct children like root coroutines. The dashed path upward stops before reaching the parent, the failure is not passed up, so a handler installed on the parent coroutine never fires.
the supervisorScope exception handler paths
A child of supervisorScope is treated like a root coroutine for exceptions, so the CoroutineExceptionHandler on that launch fires. The failure is never passed up to the coroutine that opened the scope, which is why siblings survive, and why a handler installed there never runs.
val handler = CoroutineExceptionHandler { _, e -> println("caught: $e") }

supervisorScope {
    launch(handler) {            // this handler DOES fire
        throw RuntimeException("network failed")
    }
}

supervisorScope exists specifically so one child's failure doesn't cancel its siblings, and it achieves that by never relaying a child's exception up to its own Job. Since the failure never reaches the parent, a handler sitting on the parent instead of the child never gets a chance to run.

val handler = CoroutineExceptionHandler { _, e -> println("caught: $e") }

viewModelScope.launch(handler) {      // this handler never fires for the failure below
    supervisorScope {
        launch {
            throw RuntimeException("network failed") // uncaught, no handler on this launch
        }
    }
}

The rule that actually matters, install the CoroutineExceptionHandler on the individual children you launch inside a supervisorScope, not on the coroutine that opened the scope, since that's the one place the exception is guaranteed to reach. async is the one builder a handler never helps regardless of where you put it, since async always relies on you calling .await() to see its failure.

Read more Coroutine exceptions handling (opens in a new tab)

Dispatchers & Threading

What are Dispatchers in Kotlin Coroutines? Name all of them.

Tier: EssentialDifficulty: Easy

A Dispatcher decides which thread or thread pool a coroutine actually runs on. You pick one when you launch a coroutine, and Kotlin handles moving the work there for you.

There are four you should know.

  • Dispatchers.Main, the Android main thread, for anything that touches the UI.
  • Dispatchers.Default, a pool sized to your CPU core count, for CPU heavy work like sorting a big list, parsing JSON, or image processing.
  • Dispatchers.IO, a much larger pool meant for blocking work like network calls, database queries, and file reads, since those threads spend most of their time waiting rather than computing.
  • Dispatchers.Unconfined, which doesn't confine the coroutine to any particular thread, it starts on the caller's thread and resumes on whatever thread the suspending call happened to finish on. You rarely reach for this one directly outside of tests.
viewModelScope.launch(Dispatchers.IO) {
    val data = repository.fetchFromNetwork()
    withContext(Dispatchers.Main) {
        updateUi(data)
    }
}

In practice, most Android code only ever needs Main, Default, and IO. viewModelScope and lifecycleScope already default to Main, so you switch to IO or Default with withContext only for the specific piece of work that needs it.

What is the difference between Dispatchers.Default and Dispatchers.IO?

Tier: EssentialDifficulty: Medium

Dispatchers.Default is for CPU bound work, Dispatchers.IO is for blocking I/O work, and the difference comes down to how big their thread pools are and what those threads spend their time doing.

Default is sized to the number of CPU cores on the device, usually somewhere between two and eight threads. That makes sense for work like sorting a large list, parsing JSON, or running a diff, tasks that are actively using the CPU the whole time, so there's no benefit to having more threads than cores.

IO uses a much bigger pool, up to 64 threads by default, because I/O threads spend most of their time blocked waiting on a network response, a disk read, or a database query rather than actually computing anything. Since those threads are mostly idle while waiting, you can have far more of them alive at once without overloading the CPU.

viewModelScope.launch(Dispatchers.Default) {
    val sorted = hugeList.sortedBy { it.score } // CPU work
}

viewModelScope.launch(Dispatchers.IO) {
    val response = api.getUser(id) // blocking network call
}

They actually share the same underlying elastic thread pool in the coroutines library, IO is just a view onto it with a much higher thread limit. The practical rule is simple, use Default for computation, use IO for anything that talks to the network, disk, or a database.

On which thread does Dispatchers.Default execute a task?

Tier: CommonDifficulty: Easy

Dispatchers.Default runs a task on a shared background thread pool sized to the number of CPU cores on the device, never on the main thread.

That pool has at least two threads even on a single core device, and scales up with more cores, since it's meant for CPU bound work like sorting, parsing, or image processing, where having more threads than cores doesn't help and just adds contention.

viewModelScope.launch(Dispatchers.Default) {
    val sorted = hugeList.sortedBy { it.score } // runs on a Default pool thread
}

It's worth knowing that Dispatchers.Default and Dispatchers.IO actually share the same underlying elastic thread pool in the coroutines library, Default is just the view of it capped at the CPU core count, while IO is a view of it allowed to grow much larger, since IO threads spend most of their time blocked waiting rather than computing.

What is the difference between Dispatchers.Main.immediate and Dispatchers.Main?

Tier: CommonDifficulty: Hard

Dispatchers.Main always posts your coroutine to the back of the main thread's message queue, even if you're already running on the main thread, while Dispatchers.Main.immediate checks first and runs immediately if you're already there.

That difference sounds small, but it's the classic cause of a one frame UI flicker. If a button click already runs on the main thread, and you resume a coroutine with Dispatchers.Main, it doesn't run right away, it gets in line behind whatever the message queue is currently processing, so the update lands one frame later than you'd expect.

// posts to the queue even when already on main, can flicker a frame late
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
    viewModel.userName.collect { name -> nameTextView.text = name }
}

// runs immediately if already on main, no extra frame
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main.immediate) {
    viewModel.userName.collect { name -> nameTextView.text = name }
}

Dispatchers.Main.immediate falls back to exactly the same posting behavior as plain Dispatchers.Main when you're not already on the main thread, so there's no downside to defaulting to it. That's exactly what viewModelScope and lifecycleScope use internally, and it's the right default for UI collection code, plain Dispatchers.Main is mostly useful when you specifically need to guarantee your code runs after the current frame's work, not before it.

Coroutines in Practice

How do you use Retrofit with Kotlin Coroutines?

Tier: CommonDifficulty: Easy

Mark your Retrofit interface methods as suspend, and Retrofit calls them directly as coroutines, no Call object, no enqueue(), no manual callback.

interface ApiService {
    @GET("users")
    suspend fun getUsers(): List<User>
}

class UserRepository(private val api: ApiService) {
    suspend fun loadUsers(): List<User> = api.getUsers()
}

Calling it from a ViewModel looks like any other suspend call, wrap it in viewModelScope.launch and handle failure with a normal try and catch. Retrofit already runs the actual network call on a background thread internally, so you don't need to wrap it in withContext(Dispatchers.IO) yourself.

viewModelScope.launch {
    try {
        val users = repository.loadUsers()
        _uiState.value = UiState.Success(users)
    } catch (e: IOException) {
        _uiState.value = UiState.Error(e.message)
    }
}

This replaces the old callback based Call<T>.enqueue() pattern entirely, the suspend function either returns the parsed body or throws, which is why most modern Retrofit setups don't bother with a CallAdapter.Factory for RxJava or LiveData anymore.

How do you use Room Database with Kotlin Coroutines?

Tier: CommonDifficulty: Easy

Mark your Room DAO methods as suspend, and Room runs them off the main thread automatically, no AsyncTask, no manual executor.

@Dao
interface UserDao {
    @Insert
    suspend fun insert(user: User)

    @Query("SELECT * FROM user WHERE id = :id")
    suspend fun getUser(id: Int): User
}

Calling these from a ViewModel is just a normal suspend call inside viewModelScope.launch. Room throws IllegalStateException if you try to call a suspend DAO method from the main thread synchronously, which is exactly the safety net that catches you if you forget the suspend keyword somewhere.

viewModelScope.launch {
    val user = userDao.getUser(id)
    _user.value = user
}

For a stream of results instead of a one time read, a Room DAO method can return Flow<T> instead of using suspend, which updates automatically whenever the underlying table changes, without you needing to requery it yourself.

@Query("SELECT * FROM user")
fun getAllUsers(): Flow<List<User>>

How do you convert a callback-based API to Coroutines in Kotlin?

Tier: CommonDifficulty: Medium

Wrap the callback in suspendCancellableCoroutine, call the library as usual, and resume the Continuation from inside its callback instead of returning through a normal callback chain.

suspend fun fetchUser(id: String): User = suspendCancellableCoroutine { cont ->
    val call = api.getUser(id, object : Callback<User> {
        override fun onSuccess(user: User) = cont.resume(user)
        override fun onFailure(error: Throwable) = cont.resumeWithException(error)
    })

    cont.invokeOnCancellation {
        call.cancel() // stop the real network call if the coroutine is cancelled
    }
}

Once fetchUser exists, calling it looks like any other suspend function, no nested callbacks, no manual thread hopping.

viewModelScope.launch {
    val user = fetchUser("42")
    updateUi(user)
}

The one detail worth getting right is invokeOnCancellation. If the underlying library supports cancelling an in flight call, wire it up there, otherwise the real network request or listener keeps running even after the coroutine that was waiting on it has been cancelled, wasting work and possibly leaking a reference.

How do you make parallel multiple network calls using Kotlin Coroutines?

Tier: CommonDifficulty: Medium

Start each call with async instead of calling them one after another, that launches them concurrently, then call .await() on each once you actually need the results.

suspend fun loadDashboard(): Dashboard = coroutineScope {
    val usersDeferred = async { api.getUsers() }
    val postsDeferred = async { api.getPosts() }

    Dashboard(
        users = usersDeferred.await(),
        posts = postsDeferred.await()
    )
}

Both requests start immediately, back to back, instead of waiting for one to finish before the other begins, so the total time is close to whichever call is slower, not the sum of both. Wrapping them in coroutineScope keeps this structured, if either call fails, the other is cancelled automatically instead of finishing pointlessly in the background.

For more than a couple of calls, awaitAll() reads a little cleaner than chaining .await() calls by hand.

val results = listOf(async { api.getA() }, async { api.getB() }, async { api.getC() }).awaitAll()

Less common, worth knowing

These come up less often. Skim them once you are comfortable with everything above.

Fundamentals & Suspension

What does yield do in Kotlin Coroutines?

Tier: Less commonDifficulty: Medium

yield() suspends the current coroutine briefly to let other coroutines on the same dispatcher get a turn, and it also acts as a cancellation check point, throwing CancellationException there if the coroutine's Job has already been cancelled.

It matters most inside CPU heavy loops that never naturally suspend. A loop doing a big computation with no delay or network call inside it never hits a suspension point on its own, so it can hog its thread and never notice it's been cancelled until it finishes.

suspend fun computeUntilCancelled() {
    var i = 0
    while (isActive) {
        i++
        if (i % 1000 == 0) yield() // give other coroutines a turn, check for cancellation
    }
}

It's similar to ensureActive() in that both check for cancellation, but yield() also genuinely suspends and gives the scheduler a chance to run something else on that thread first, while ensureActive() just checks and throws without suspending. Use yield() in a long computation you want to stay responsive and cancellable, and reach for real suspend calls like delay when you actually need to wait for something.

How does a Coroutine switch context?

Tier: Less commonDifficulty: Hard

A coroutine switches context by suspending itself on the current dispatcher, packaging up its Continuation, and asking the new dispatcher to resume it on one of its own threads.

This happens at a suspension point, usually withContext(dispatcher) or a dispatcher passed to launch or async. The coroutine doesn't move a running thread anywhere, since it isn't a thread. It just stops, hands its Continuation to the target dispatcher's task queue, and that dispatcher picks it up on whichever of its own threads is free.

viewModelScope.launch(Dispatchers.Main) {
    val result = withContext(Dispatchers.IO) {
        repository.loadFromDisk() // now running on an IO thread
    }
    updateUi(result) // back on Main automatically
}

withContext suspends the current coroutine, runs its block on the new dispatcher, and when that block finishes, suspends again and resumes back on the original dispatcher. Nothing blocks either thread while the switch happens, the coroutine simply isn't scheduled anywhere during the brief handoff.

Coroutines in Practice

How do you implement debounce using Coroutines?

Tier: Less commonDifficulty: Medium

Cancel the previously scheduled coroutine every time a new event comes in, and only actually run the action after the delay passes with no new event to cancel it.

class Debouncer(private val scope: CoroutineScope, private val waitMs: Long = 300) {
    private var job: Job? = null

    fun submit(action: suspend () -> Unit) {
        job?.cancel()
        job = scope.launch {
            delay(waitMs)
            action()
        }
    }
}

Every call to submit() cancels whatever was previously queued and starts a fresh timer, so only the last call within the wait window actually survives to run action(). If events keep arriving faster than waitMs, nothing ever fires until they stop.

val debouncer = Debouncer(viewModelScope)

searchBox.doOnTextChanged { text, _, _, _ ->
    debouncer.submit { performSearch(text.toString()) }
}

This is the same pattern behind Flow's built in debounce() operator, if the events are already flowing through a StateFlow or Flow, reach for .debounce(300) instead of hand rolling this Job cancelling version yourself.