Coroutines are one of the most heavily tested topics in Android interviews. Interviewers want to know that you don’t just use viewModelScope.launch blindly — they want to see that you understand how coroutines work internally, how cancellation propagates, when to use which dispatcher, and how structured concurrency keeps your app safe. This guide covers the coroutine interview questions you’re most likely to face, from beginner to advanced.


Fundamentals

1. What is a coroutine? How is it different from a thread?

// A coroutine is a lightweight unit of execution that can SUSPEND
// without blocking the thread it runs on.

// Thread: ~1-2 MB stack memory, expensive to create/destroy, OS-managed
// Coroutine: ~few hundred bytes, very cheap, managed by Kotlin runtime

// Key difference: suspension vs blocking
// Thread.sleep(1000)  → thread is BLOCKED, can't do anything else
// delay(1000)         → coroutine SUSPENDS, thread is FREE for other work

// 100,000 threads → OutOfMemoryError
// 100,000 coroutines → runs fine (they share a small thread pool)

2. What is a suspend function? What happens under the hood?

// A suspend function can pause and resume without blocking the thread.
// It can only be called from a coroutine or another suspend function.

suspend fun fetchUser(): User {
    val response = api.getUser()   // suspends here
    return parseUser(response)      // resumes here
}

// Under the hood, the compiler transforms it using CPS (Continuation Passing Style):
// 1. Adds a hidden Continuation parameter
// 2. Converts suspension points into a state machine with labels
// 3. Returns COROUTINE_SUSPENDED when pausing
// 4. Calls continuation.resume() when the result is ready

// The suspend keyword itself doesn't make code non-blocking —
// it only ALLOWS the function to suspend. You still need
// withContext(Dispatchers.IO) for blocking operations.

3. What is a Continuation?

// A Continuation is a callback that represents "what to do next"
// when a suspended coroutine is ready to resume.

interface Continuation<in T> {
    val context: CoroutineContext
    fun resumeWith(result: Result<T>)
}

// When a coroutine suspends at api.getUser():
// 1. The current state is saved in a Continuation object
// 2. The thread is freed
// 3. When the network response arrives, continuation.resume(user) is called
// 4. The coroutine resumes from where it paused

// You interact with Continuations directly when bridging callbacks:
suspend fun getLocation(): Location = suspendCancellableCoroutine { cont ->
    locationClient.getLastLocation { location ->
        cont.resume(location)   // resume the coroutine with the result
    }
    cont.invokeOnCancellation { locationClient.cancel() }
}

4. What is the difference between launch, async, and withContext?

// launch — BUILDER: creates a new coroutine, returns Job, no result
// Use for: fire-and-forget work
viewModelScope.launch { saveAnalytics() }

// async — BUILDER: creates a new coroutine, returns Deferred<T>
// Use for: parallel work where you need results
val user = async { fetchUser() }
val posts = async { fetchPosts() }
show(user.await(), posts.await())

// withContext — SUSPEND FUNCTION: switches dispatcher, returns result
// Use for: sequential dispatcher switching
val data = withContext(Dispatchers.IO) { db.query() }
// Does NOT create a new coroutine — runs in the current one

// Common mistake: using viewModelScope.async {} as entry point
// async should be used INSIDE launch/coroutineScope for parallel work

Dispatchers

5. What are the different dispatchers and when do you use each?

// Dispatchers.Main — UI thread, for updating views and state
// Dispatchers.Main.immediate — same but skips re-dispatch if already on Main
// Dispatchers.IO — up to 64 threads, for network/file/database (blocking I/O)
// Dispatchers.Default — CPU core count threads, for heavy computation
// Dispatchers.Unconfined — starts on current thread, resumes anywhere (testing only)

// Key insight: IO and Default SHARE the same thread pool
// Switching between them is cheap (no actual thread switch)

// viewModelScope uses Main.immediate by default
// Retrofit and Room suspend functions are already main-safe

6. Can you create a custom dispatcher? When would you?

// Modern approach: limitedParallelism
val dbDispatcher = Dispatchers.IO.limitedParallelism(1)   // single-threaded for DB
val downloadDispatcher = Dispatchers.IO.limitedParallelism(4)   // max 4 downloads

// Reuses existing IO pool — no thread leaks, no cleanup needed

// Legacy approach: from Java Executors
val custom = Executors.newFixedThreadPool(4).asCoroutineDispatcher()
custom.close()   // ⚠️ must close manually or threads leak

// When to create custom dispatchers:
// - Rate-limited APIs (limit concurrent requests)
// - Sequential operations (single-thread for DB writes)
// - Isolating work from the shared pool

Structured Concurrency & Jobs

7. What is structured concurrency? What are its three rules?

// Structured concurrency means every coroutine has a parent,
// and the parent-child relationship enforces three rules:

// Rule 1: Parent waits for all children to complete
launch {
    launch { delay(1000); println("child done") }
    println("parent body done")   // prints first, but parent Job waits
}

// Rule 2: Cancelling parent cancels ALL children
parentJob.cancel()   // all children cancelled immediately

// Rule 3: Child failure cancels parent and siblings (with regular Job)
launch {
    launch { throw RuntimeException() }   // child fails
    launch { delay(1000) }                // cancelled by sibling failure
}

// These rules prevent coroutine leaks and ensure cleanup

8. What is the difference between Job and SupervisorJob?

// Job — Rule 3 applies: child failure cancels parent and siblings
val scope = CoroutineScope(Job())
scope.launch { throw RuntimeException() }   // fails
scope.launch { delay(1000); println("B") }  // cancelled!

// SupervisorJob — child failure does NOT affect parent or siblings
val scope = CoroutineScope(SupervisorJob())
scope.launch { throw RuntimeException() }   // fails
scope.launch { delay(1000); println("B") }  // ✅ still runs

// viewModelScope uses SupervisorJob — so one failing coroutine
// doesn't kill all other coroutines in the ViewModel

// ⚠️ SupervisorJob only protects DIRECT children
// Grandchildren follow regular Job rules:
scope.launch {   // direct child of supervisor
    launch { throw RuntimeException() }   // grandchild fails
    launch { println("C") }               // ❌ cancelled! (regular Job here)
}

// Use supervisorScope {} inside a coroutine for independent children

9. What happens if you pass Job() to a launch?

// ❌ Breaks structured concurrency — creates a DETACHED coroutine
viewModelScope.launch(Job()) {
    delay(10_000)
    // This coroutine is NOT cancelled when ViewModel clears!
    // Memory leak — it continues running after the screen is gone
}

// Why? Job() creates a NEW parent, breaking the link to viewModelScope's Job
// The coroutine is no longer a child of viewModelScope

// ✅ Never pass Job() or SupervisorJob() to launch
viewModelScope.launch {
    // Properly linked to viewModelScope — cancelled automatically
}

10. What is the difference between coroutineScope and supervisorScope?

// coroutineScope — one child fails → ALL children cancelled (all-or-nothing)
suspend fun loadBoth() = coroutineScope {
    val a = async { fetchA() }   // if this fails...
    val b = async { fetchB() }   // ...this is cancelled
    Pair(a.await(), b.await())
}

// supervisorScope — children are independent
suspend fun loadBoth() = supervisorScope {
    val a = async { fetchA() }   // if this fails...
    val b = async { fetchB() }   // ...this keeps running
    val resultA = try { a.await() } catch (e: Exception) { null }
    val resultB = try { b.await() } catch (e: Exception) { null }
    Pair(resultA, resultB)
}

// coroutineScope: "I need ALL results, or none"
// supervisorScope: "Give me whatever succeeds"

Cancellation

11. How does coroutine cancellation work?

// Cancellation is COOPERATIVE — the coroutine must check for it
// 1. job.cancel() sets a cancellation flag
// 2. At the next SUSPENSION POINT, CancellationException is thrown
// 3. The coroutine stops

val job = launch {
    delay(1000)          // suspension point — cancellation checked here
    println("Done")      // never runs if cancelled during delay
}
job.cancel()

// CPU-heavy code with no suspension points is NOT cancellable:
launch {
    var sum = 0L
    for (i in 1..1_000_000L) {
        sum += i   // no suspension point — runs even after cancel()
    }
}

// Make it cancellable with ensureActive(), isActive, or yield()
launch {
    for (i in 1..1_000_000L) {
        ensureActive()   // throws CancellationException if cancelled
        sum += i
    }
}

12. Why must you always re-throw CancellationException?

// CancellationException is NOT a failure — it's a control signal
// Swallowing it breaks structured concurrency:

// ❌ Coroutine ignores cancellation — keeps running
try { suspendingCall() } catch (e: Exception) { log(e) }

// ✅ Cancellation propagates correctly
try { suspendingCall() }
catch (e: CancellationException) { throw e }
catch (e: Exception) { log(e) }

// If you swallow CancellationException:
// - Parent thinks the child is still running
// - Scope cancellation doesn't work
// - viewModelScope cleanup fails on ViewModel destruction

13. How do you run suspending cleanup code in a cancelled coroutine?

// Problem: in finally, the coroutine is already cancelled
// Suspension points throw CancellationException immediately

launch {
    try {
        doWork()
    } finally {
        delay(100)               // ❌ throws CancellationException!
        database.closeSession()  // never reached
    }
}

// Solution: withContext(NonCancellable)
launch {
    try {
        doWork()
    } finally {
        withContext(NonCancellable) {
            delay(100)               // ✅ runs
            database.closeSession()  // ✅ runs
        }
    }
}

Exception Handling

14. How do exceptions propagate in launch vs async?

// launch — exception propagates UP to parent immediately
viewModelScope.launch {
    throw RuntimeException()   // propagates to parent
}
// try-catch AROUND launch does NOT work
// try-catch INSIDE launch works

// async — exception is stored until await() is called
val deferred = viewModelScope.async {
    throw RuntimeException()   // stored, not thrown yet
}
try {
    deferred.await()   // exception thrown HERE
} catch (e: Exception) {
    // ✅ caught
}

// ⚠️ But async ALSO propagates to parent (it's still a child Job)
// Even without await(), the parent scope may be cancelled

15. What is CoroutineExceptionHandler? Where can you install it?

val handler = CoroutineExceptionHandler { _, exception ->
    Log.e("TAG", "Uncaught: ${exception.message}")
}

// ✅ Works — on the scope
val scope = CoroutineScope(SupervisorJob() + handler)

// ✅ Works — on the root coroutine
scope.launch(handler) { throw RuntimeException() }

// ❌ Does NOT work — on a child coroutine
scope.launch {
    launch(handler) { throw RuntimeException() }   // handler IGNORED
}

// ❌ Does NOT work with async (exceptions go to await())

// Handler is a LAST RESORT — prefer try-catch inside coroutines

Practical Patterns

16. How do you implement search debounce with coroutines?

class SearchViewModel : ViewModel() {
    private var searchJob: Job? = null

    fun onQueryChanged(query: String) {
        searchJob?.cancel()   // cancel previous search

        searchJob = viewModelScope.launch {
            delay(300)   // debounce — cancelled if user types within 300ms
            val results = withContext(Dispatchers.IO) {
                repository.search(query)
            }
            _results.value = results
        }
    }
}

17. How do you make two network calls in parallel?

viewModelScope.launch {
    try {
        val (user, posts) = coroutineScope {
            val u = async(Dispatchers.IO) { api.getUser() }
            val p = async(Dispatchers.IO) { api.getPosts() }
            Pair(u.await(), p.await())
        }
        _uiState.value = UiState.Success(user, posts)
    } catch (e: CancellationException) {
        throw e
    } catch (e: Exception) {
        _uiState.value = UiState.Error(e.message ?: "Error")
    }
}
// coroutineScope ensures: if one fails, the other is cancelled
// Total time = max(getUser, getPosts), not sum

18. How do you convert a callback API to a suspend function?

suspend fun getCurrentLocation(): Location =
    suspendCancellableCoroutine { continuation ->
        val callback = object : LocationCallback() {
            override fun onLocationResult(result: LocationResult) {
                if (continuation.isActive) {
                    continuation.resume(result.lastLocation)
                }
            }
        }

        locationClient.requestLocationUpdates(request, callback, Looper.getMainLooper())

        continuation.invokeOnCancellation {
            locationClient.removeLocationUpdates(callback)
        }
    }

// Key points:
// - Use suspendCancellableCoroutine (not suspendCoroutine) for cancellation support
// - Check continuation.isActive before resuming
// - Use invokeOnCancellation to clean up resources
// - Never resume a continuation more than once

19. What is the recommended pattern for ViewModel coroutines?

class ArticleViewModel(private val repository: ArticleRepository) : ViewModel() {

    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState

    fun load() {
        viewModelScope.launch {              // Main (entry point)
            _uiState.value = UiState.Loading
            try {
                val data = withContext(Dispatchers.IO) {
                    repository.getData()     // IO (background work)
                }
                _uiState.value = UiState.Success(data)   // Main (UI update)
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e.message ?: "Error")
            }
        }
    }
}

// Pattern: launch on Main → withContext for background → back to Main
// viewModelScope handles cancellation on ViewModel destruction

20. What is the difference between cancel() and cancelChildren()?

val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

// cancel() — kills the scope permanently, can't launch new coroutines
scope.cancel()
scope.launch { }   // silently does nothing — scope is dead

// cancelChildren() — cancels all running children, scope stays alive
scope.coroutineContext.cancelChildren()
scope.launch { }   // ✅ works — scope is still active

// Use cancelChildren() when you want to stop current work
// but keep the scope for future coroutines (e.g., pull-to-refresh)

Summary

  • Know the difference between threads and coroutines (cost, blocking vs suspension)
  • Understand Continuation and CPS — how suspend functions work under the hood
  • Know launch vs async vs withContext — builders vs suspend function, when to use each
  • Explain the three rules of structured concurrency and why they prevent leaks
  • Know Job vs SupervisorJob and that SupervisorJob only protects direct children
  • Understand cooperative cancellation — ensureActive, CancellationException re-throw
  • Know exception propagation — launch (up to parent) vs async (at await)
  • Be able to write practical patterns — debounce, parallel calls, callback bridging
  • Never pass Job() to launch — it breaks structured concurrency
  • Know the recommended ViewModel pattern — launch on Main, withContext for background

Coroutine questions separate junior from senior Android developers. Anyone can write viewModelScope.launch — but understanding CPS, structured concurrency rules, and exception propagation shows real depth. Practice these patterns in real apps, and you’ll answer any coroutine question with confidence.

Happy coding!