Post

Concurrency in Kotlin: The Coroutine Questions Senior Loops Ask, and the Answers That Hold Up

Kotlin candidates get asked about coroutines specifically: structured concurrency, cooperative cancellation, dispatchers, exception propagation, shared mutable state, and Flow versus Channel. Here are the questions, the mental model, ten compiled snippets, and the gotchas that separate people who have run coroutines in production from people who have read the guide.

Concurrency in Kotlin: The Coroutine Questions Senior Loops Ask, and the Answers That Hold Up

If your resume says Kotlin, a senior loop will ask about coroutines. Not “what is a coroutine,” but the questions that only have good answers if you have shipped them: why a cancelled coroutine kept running, why one failed request took down nine that succeeded, why the thread pool starved, and how you would fetch a thousand things with a concurrency cap and a timeout without leaking anything.

This post is the mental model in three sentences, the ten questions that come up, the gotchas, and ten snippets that were compiled and run against a test harness before publishing. It pairs with the two coding posts (part one, part two) and uses the same discipline: say the invariant, write the code, state the cost.

The Question

The forms it takes:

  • “What is the difference between a coroutine and a thread?”
  • “What does structured concurrency mean, and why does it matter?”
  • “Explain cancellation. Why might a cancelled coroutine keep running?”
  • launch versus async. coroutineScope versus supervisorScope. Why is GlobalScope discouraged?”
  • “How do you protect shared mutable state in coroutines?”
  • “What happens if you call a blocking API inside a coroutine?”
  • “How do you limit how many coroutines run at once?”
  • “How do exceptions propagate?”
  • “Flow versus Channel. Cold versus hot.”
  • “Write a function that fetches N URLs with at most 10 in flight and a two-second timeout each, tolerating partial failure.”

The last one is the whole post in one question. Everything before it is what you need to answer it.

What They Are Really Checking

  1. Do you have the model, or just the syntax? A coroutine is a suspendable computation. Suspension is not blocking. A scope owns a tree of jobs. Candidates with the model answer follow-ups they have never heard; candidates with the syntax do not.
  2. Do you know cancellation is cooperative? This is the number one production bug. The interviewer wants to hear the word “cooperative” and the mechanism.
  3. Do you know how failure propagates? One child fails; what happens to its siblings, its parent, and the caller? The answer differs between coroutineScope and supervisorScope, and between launch and async.
  4. Do you know what a dispatcher is for? Blocking on the wrong one starves the pool. Senior candidates say “withContext(Dispatchers.IO)” in the same breath as “JDBC.”
  5. Can you compose the pieces? The final question needs a scope, async, a Semaphore, withTimeoutOrNull, and a failure-handling choice, in the right nesting. That composition is the skill.

The Gotchas

Gotcha 1: Cancellation is cooperative, and CPU-bound loops do not cooperate by default. job.cancel() sets a flag. Suspending functions from the library check it. A while (true) loop doing arithmetic never does. The loop must check isActive or call ensureActive() or yield(). If you cannot explain this, you cannot explain why the cancelled coroutine is still burning a core.

Gotcha 2: Catching CancellationException and swallowing it. catch (e: Exception) catches it, because it is an IllegalStateException. So does runCatching, which catches Throwable. A coroutine that swallows its cancellation becomes a zombie: cancelled by its parent, still running. Rethrow it, always.

Gotcha 3: GlobalScope and other unstructured launches. A coroutine launched in GlobalScope has no parent. Nothing waits for it, nothing cancels it, its exceptions go to the uncaught handler. Every coroutine belongs to a scope whose lifetime is a real thing: a request, a screen, a service. Say what the scope is.

Gotcha 4: async exception semantics. In a coroutineScope, a failing async child cancels the scope immediately, whether or not anyone ever calls await(). The exception is also rethrown at await(). People expect the second and are surprised by the first. In a supervisorScope, the failure stays inside the child until await().

Gotcha 5: Blocking on Dispatchers.Default. Default has as many threads as cores. A JDBC call, Thread.sleep, or a synchronous HTTP client on it blocks a core’s worth of the pool. Wrap blocking work in withContext(Dispatchers.IO), which is sized for it, and say that even IO has a cap.

Gotcha 6: Shared mutable state with no protection. counter++ from many coroutines on Default loses updates, exactly like threads, because it is threads. @Volatile does not fix increments. Use a Mutex, an atomic, confinement to a single-threaded dispatcher, or an actor that owns the state.

Gotcha 7: runBlocking in production code. It is for main and tests. Inside a server handler it ties up a thread for the duration and, inside another coroutine, it can deadlock the dispatcher. Bridging to blocking code happens at the edge, once.

Gotcha 8: Accidental serialization. ids.map { async { fetch(it) }.await() } awaits each before launching the next. It is sequential code with extra steps. Launch all, then awaitAll().

Gotcha 9: Suspending in finally after cancellation. Once a coroutine is cancelled, any further suspension inside it throws immediately, including the delay or network call in your cleanup block. Cleanup that must suspend runs in withContext(NonCancellable).

Gotcha 10: Misplacing SupervisorJob. scope.launch(SupervisorJob()) { ... } does not make the children of that coroutine supervised. It replaces the coroutine’s parent with a detached job, breaking the structure. Supervision is a property of the scope, via supervisorScope { } or a CoroutineScope(SupervisorJob() + dispatcher) that you own.

How to Answer

Step 1: The model in three sentences

Say this first:

A coroutine is a computation that can suspend without blocking its thread, so a handful of threads can run thousands of them. Every coroutine has a Job, jobs form a tree, and a scope owns a subtree: the parent does not complete until its children do, cancellation flows down the tree, and failure flows up unless a supervisor stops it. Dispatchers decide which threads run the code; the structure is independent of them.

That is structured concurrency, cancellation, and dispatching in one breath, and it is the model every follow-up hangs off.

flowchart TB
    S[Scope: one request] --> P[Parent job]
    P --> A[async: fetch user]
    P --> B[async: fetch orders]
    P --> C[launch: audit log]
    B -.->|fails| P
    P -.->|cancels siblings| A & C
    P -.->|rethrows to caller| S
    style P fill:#2d6a4f,color:#fff

Under coroutineScope, the dotted path is what happens. Under supervisorScope, the failure of B stops at B.

Step 2: Structured concurrency in code

The right way to run things in parallel, and the accidental way to run them in sequence:

1
2
3
4
5
6
7
8
9
10
11
import kotlinx.coroutines.*

// Parallel: all launched, then all awaited. Scope waits for every child.
suspend fun fetchAll(ids: List<Int>, fetch: suspend (Int) -> String): List<String> = coroutineScope {
    ids.map { id -> async { fetch(id) } }.awaitAll()
}

// Sequential by accident: each await completes before the next async starts.
suspend fun fetchAllSlowly(ids: List<Int>, fetch: suspend (Int) -> String): List<String> = coroutineScope {
    ids.map { id -> async { fetch(id) }.await() }
}

What to say: coroutineScope creates a child scope that inherits the caller’s context, waits for every async, and if any child fails, cancels the rest and rethrows. Nothing leaks. The second function is the bug in gotcha 8, and it passes every unit test that does not measure time.

Step 3: Cancellation

Cooperative, so the code has to check:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import kotlinx.coroutines.*

// Stops promptly when cancelled because the loop checks isActive.
fun CoroutineScope.crunchUntilCancelled(step: () -> Unit): Job = launch(Dispatchers.Default) {
    while (isActive) step()
}

// Never swallow cancellation. Handle real failures; rethrow the cancel.
suspend fun riskyWithRecovery(work: suspend () -> String): String = try {
    work()
} catch (e: CancellationException) {
    throw e
} catch (e: Exception) {
    "recovered"
}

// Cleanup that must suspend after cancellation runs under NonCancellable.
suspend fun withGuaranteedCleanup(body: suspend () -> Unit, cleanup: suspend () -> Unit) {
    try {
        body()
    } finally {
        withContext(NonCancellable) { cleanup() }
    }
}

The three points: a CPU loop checks isActive (or calls ensureActive() or yield()); CancellationException is rethrown, never swallowed; and cleanup that suspends is wrapped in NonCancellable. Also say that cancellation cannot interrupt a blocking call. A thread stuck in a socket read stays stuck; the coroutine is marked cancelled and the thread is returned when the read finishes. That is why blocking work gets a timeout at the client level, not just withTimeout.

Step 4: Dispatchers and limiting concurrency

Dispatcher Threads Use for Do not
Dispatchers.Default Number of cores CPU-bound work: parsing, sorting, hashing Block on it. One blocked thread is a core gone
Dispatchers.IO Elastic, capped at 64 by default Blocking calls: JDBC, files, synchronous clients Assume it is unbounded. It shares threads with Default and it has a cap
Dispatchers.Main The UI thread Touching UI state on Android or desktop Exist on a server. It throws if there is no main loop
Dispatchers.Unconfined Whatever thread resumes it Almost nothing outside tests Use it because it looked fast
A single-thread dispatcher you create One Confining mutable state without locks Forget to close it

Blocking work goes to IO, and concurrency gets a cap that you chose:

1
2
3
4
5
6
7
8
9
10
11
12
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit

// Blocking API, correct dispatcher.
suspend fun readBlocking(read: () -> ByteArray): ByteArray = withContext(Dispatchers.IO) { read() }

// At most `limit` bodies in flight at once, still structured.
suspend fun <T, R> mapConcurrently(items: List<T>, limit: Int, f: suspend (T) -> R): List<R> = coroutineScope {
    val gate = Semaphore(limit)
    items.map { item -> async { gate.withPermit { f(item) } } }.awaitAll()
}

What to say: the Semaphore bounds work in flight, not coroutines created. All the asyncs exist immediately, which is cheap; only limit of them are inside withPermit at a time. If the items number in the millions, produce them lazily with a Flow or a Channel instead of building the list. On newer library versions Dispatchers.IO.limitedParallelism(n) gives a bounded dispatcher for the same purpose.

Step 5: Exceptions

  launch async
Where the exception surfaces Immediately, to the parent; if there is no parent, to CoroutineExceptionHandler or the thread’s uncaught handler At await(), and also to the parent immediately if the scope is not a supervisor
Return value Job Deferred<T>
Use for Fire-and-track side effects Computing a value you will await
  coroutineScope supervisorScope
One child fails Cancels the other children, then rethrows to the caller Other children continue; the failure is delivered at await() or to the handler
Use for All-or-nothing work: assemble one response from several calls Independent work: process a batch where one bad item must not stop the rest

Partial failure done correctly:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import kotlinx.coroutines.*

// Siblings survive a failing child; each result carries its own outcome.
suspend fun <T> fetchTolerant(ids: List<Int>, fetch: suspend (Int) -> T): List<Result<T>> = supervisorScope {
    ids.map { id -> async { fetch(id) } }
       .map { deferred ->
           try {
               Result.success(deferred.await())
           } catch (e: CancellationException) {
               throw e
           } catch (e: Exception) {
               Result.failure(e)
           }
       }
}

What to say: supervisorScope keeps the failure inside the child; await() rethrows it; the try turns it into a value; and the CancellationException branch is there because gotcha 2 applies inside this function too. runCatching would have been shorter and wrong.

Step 6: Shared mutable state

Approach How Cost Use when
Mutex mutex.withLock { } around every access A suspension point per access; never blocks a thread Compound operations on a small critical section
Atomics AtomicInteger, AtomicReference Lock-free, single-variable only Counters and flags
Confinement Run every access on one single-threaded dispatcher via withContext A context switch per access Larger state, no locks in the code
Actor or channel One coroutine owns the state and processes messages A message per operation; ordering for free State with many operations and a natural queue
Immutability Do not share mutable things Copying Whenever you can get away with it
1
2
3
4
5
6
7
8
9
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

class Counter {
    private val mutex = Mutex()
    private var value = 0
    suspend fun increment() { mutex.withLock { value++ } }
    suspend fun get(): Int = mutex.withLock { value }
}

What to say: Mutex is not synchronized. It suspends instead of blocking, so it is safe to hold across a suspension point, and it is not reentrant, so calling a locked function from inside the lock deadlocks. Both of those are follow-up questions.

Step 7: Flow versus Channel

  Flow Channel
Temperature Cold: the block runs per collector, from the start Hot: values exist whether or not anyone receives
Cardinality Each collector gets the whole stream Each value goes to exactly one receiver
Backpressure Built in: the producer suspends until the collector takes the value; buffer, conflate, and collectLatest tune it Capacity you choose: rendezvous, buffered, unlimited, or conflated
Use for A pipeline of transformations over an asynchronous sequence Handing work between coroutines; fan-out to workers
1
2
3
4
5
6
7
8
9
10
11
12
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

// A cold stream of pages; nothing is fetched until someone collects.
fun pages(fetchPage: suspend (Int) -> List<String>): Flow<String> = flow {
    var page = 0
    while (true) {
        val items = fetchPage(page++)
        if (items.isEmpty()) break
        items.forEach { emit(it) }
    }
}

What to say: cold means two collectors fetch twice, which is either the feature or the bug. SharedFlow and StateFlow are the hot variants for broadcasting; a Channel is the hot variant for work distribution.

Step 8: The question that is the whole post

“Fetch N URLs with at most maxConcurrent in flight, a per-call timeout, tolerating partial failure.”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit

suspend fun fetchWithBudget(
    urls: List<String>,
    maxConcurrent: Int,
    perCallTimeoutMs: Long,
    fetch: suspend (String) -> String
): Map<String, String?> = supervisorScope {
    val gate = Semaphore(maxConcurrent)
    urls.map { url ->
        async {
            url to gate.withPermit {
                try {
                    withTimeoutOrNull(perCallTimeoutMs) { fetch(url) }
                } catch (e: CancellationException) {
                    throw e
                } catch (e: Exception) {
                    null
                }
            }
        }
    }.awaitAll().toMap()
}

Walk it in the order the interviewer will ask:

  • supervisorScope, so one bad URL does not cancel the rest, and so the function still does not return until every child has finished or been cancelled.
  • Semaphore(maxConcurrent) with withPermit around the call, so the cap is on work in flight, and the permit is released on every path including timeout and failure.
  • withTimeoutOrNull, which cancels the call and yields null instead of throwing. Say that this only works if fetch is cooperative; a blocking client needs its own timeout.
  • The catch order. Cancellation is rethrown so the whole thing can still be cancelled from outside. Everything else becomes null for that URL.
  • awaitAll().toMap(), so the result is complete: every URL has an entry, and the caller can distinguish “failed or timed out” from “missing.”

Then the cost: the asyncs are all created up front, which is fine for thousands and wrong for millions, where you would feed a Channel from a Flow and run maxConcurrent worker coroutines instead.

Follow-Up Questions to Expect

  • “Why did my cancelled coroutine keep running?” It was CPU-bound and never checked, or something caught and swallowed the CancellationException. Look for catch (e: Exception) and runCatching.
  • “Is Mutex reentrant?” No. A function that takes the lock and calls another function that takes the lock deadlocks. Design the critical section so it does not happen.
  • “What is the difference between withContext and launch?” withContext switches context for a block and suspends until it finishes, returning its value. launch starts a new coroutine and returns immediately. One is sequential; the other is concurrent.
  • “How would you test this?” The kotlinx-coroutines-test library’s runTest with virtual time: assert the timeout fires without waiting for it. Inject the dispatcher; never hard-code Dispatchers.IO in code you want to test.
  • “Coroutines or virtual threads?” Different answers to the same problem. Virtual threads make blocking cheap and keep the imperative model; coroutines make suspension explicit and add structured concurrency and Flow. On a modern JVM they coexist, and the honest answer is that the structure is the part coroutines still win on.
  • “How does this interact with a thread-local, such as a request ID?” Thread-locals do not follow a coroutine across dispatcher threads. Put it in the CoroutineContext as an element, or use ThreadContextElement to bridge for logging frameworks.

Key Takeaways

  • The model: suspendable computations, a tree of jobs owned by scopes, dispatchers as a separate concern.
  • Cancellation is cooperative. CPU loops check isActive. CancellationException is always rethrown. runCatching swallows it.
  • coroutineScope is all-or-nothing; supervisorScope is partial failure. async in a non-supervisor scope fails the scope immediately.
  • Blocking work goes to Dispatchers.IO via withContext, and IO has a cap too.
  • Bound concurrency with a Semaphore around the work, not around coroutine creation.
  • Cleanup that suspends runs in NonCancellable.
  • Mutex suspends, is not reentrant, and is safe across suspension points. synchronized is none of those.
  • Launch all, then awaitAll(). Awaiting in the map is sequential code.

Further Reading

This post is licensed under CC BY 4.0 by the author.