Find here

Not a Medium member? Read this story for free here.

This isn’t another “how to use coroutines” article.

This is for those interview moments when the question sounds simple — but answering it isn’t.
Like when you’re asked what can go into a CoroutineContext, and then immediately hit with:

What happens if you pass _Job() + Job() + Job()_ to a _CoroutineScope_?

The code is valid. But the behaviour? Not what you’d expect — unless you know the internals.

This article is a collection of tricky coroutine questions — not academic puzzles, not trivia, but real examples that reveal how deep your understanding actually goes.

Whether you’re preparing for interviews or just want to challenge what you think you know, you’ll find value here.

Question 1: What happens if you pass Job() + Job() + Job() to a CoroutineScope?

Most developers know that CoroutineScope takes a CoroutineContext, and that you can combine multiple context elements using the + operator. So what happens if you chain a few Job() instances together?

The code used to compile and run with no errors. But does it actually create a scope with three jobs?
Not quite.

Why this is a trick question

In CoroutineContext, each element is identified by a unique key.
Job and SupervisorJob share the same key: Job.Key.
When you combine context elements using the + operator, any element with a duplicate key replaces the previous one.

So in this case:

val scope = CoroutineScope(Job() + Job() + Job())

You’re not stacking three jobs — you’re replacing one with the next.
Only the last Job() remains in the scope’s context. The first two are silently discarded.

While this example is artificial, that’s exactly what makes it useful in interviews — it reveals whether you understand how CoroutineContext works beyond the basics.

Why this question exists

This isn’t about catching a syntax mistake — the code is perfectly valid.
The goal is to check whether you understand that CoroutineContext behaves more like a map than a list — where keys matter and the last one wins.

It also shows whether you’ve gone beyond surface-level usage and understand how coroutine contexts are actually structured.

What to remember

When combining elements of the same type in a _CoroutineContext__, only the last one is kept.
__CoroutineContext_ behaves like a map, not a list.

Bonus gotcha

Even if the question is rephrased — for example:

val ==scope = CoroutineScope(Job() + Job() + SupervisorJob())==

…the behavior stays the same.

SupervisorJob is still a Job under the hood and uses the same key (Job.Key).
So again, only the last element is kept — in this case, the SupervisorJob.

This kind of variation often appears in interviews to test whether you understand the principle — not just the syntax.

⚠️ Note:
In recent versions of kotlinx.coroutines, combining two Job instances using + is no longer allowed — it results in a compilation error.
This change was introduced to prevent confusion: Job + Job is meaningless, since the second one always replaces the first.

That means code like this will now fail to compile:

val scope = CoroutineScope(Job() + Job() + Job())

Even though this code no longer compiles, the question still appears in interviews — not to test your memory of syntax, but to check whether you understand how CoroutineContext merging and key replacement work.

Question 2: What happens if an exception is thrown inside an async coroutine — but you never call await()?

It sounds simple: an exception is thrown, so the program should crash — right? Not quite.

Why this is a trick question

Unlike launch, where exceptions are immediately propagated, async stores exceptions inside the resulting Deferred.
If you never call await(), the exception stays hidden.

Here’s a minimal example:

val scope = CoroutineScope(Dispatchers.Default)

fun main() {
scope.async {
throw RuntimeException(“Something went wrong”)
}
println(“Done”)
Thread.sleep(1000) // Give coroutine time to run
}

Output:

Done

No crash. No logs. The coroutine failed — but no one observed it.

Why this question exists

It checks whether you understand how error handling works in coroutines — and how async behaves differently from launch.

It also reveals a common misuse: using async when you don’t need a result.

What to remember

Exceptions in _async_ are only thrown when you call _await()_.
If you skip
 _await()_, the error may go completely unnoticed.

Bonus clarification

In runBlocking, the behavior is different:

fun main() = runBlocking {
async {
throw RuntimeException(“Something went wrong”)
}
println(“Done”)
}

Here, the exception will be reported, because runBlocking waits for all child coroutines and rethrows any unhandled failures.

But in regular app scopes — like CoroutineScope(Dispatchers.Default) — this doesn’t happen automatically.

Bonus insight

If you don’t need the result, use launch instead.
It reports exceptions immediately and avoids silent failures.

Question 3: What are the behavioral differences between withContext(Dispatchers.IO) and launch(Dispatchers.IO)?

These two look very similar. Both use the same dispatcher. Both run code on a background thread.

withContext(Dispatchers.IO) {
// work
}

launch(Dispatchers.IO) {
// work
}

So… is there a real difference? Yes — and it matters more than it seems.

Why this is a trick question

Even though the code looks similar, these two behave differently:

  • withContext is a suspending function. It waits for the block to complete before moving on.
  • launch starts a new coroutine and returns immediately. The rest of the code continues without waiting.

Here’s a minimal example:

fun main() = runBlocking {
println(“Start”)

launch(Dispatchers.IO) {  
    delay(100)  
    println("Inside launch")  
}  

withContext(Dispatchers.IO) {  
    delay(50)  
    println("Inside withContext")  
}  

println("End")  

}

Output:

Start
Inside withContext
End
Inside launch

This shows the key difference:

  • withContext finishes before "End" is printed.
  • launch runs in parallel and prints its message later.

Why this question exists

It checks whether you understand how coroutine builders affect the order of execution.
Confusing these two can lead to subtle timing bugs — especially when the code depends on when something finishes.

What to remember

_withContext_ waits for the block to complete._launch_ runs code in parallel and doesn’t wait.
Use
 _withContext_ when the result or timing of the code matters.