Coroutines
Suspending computation on the JVM — how a coroutine differs from a thread, suspend as continuation passing, the launch/async/runBlocking builders, structured concurrency and CoroutineScope, CoroutineContext, the Main/IO/Default dispatchers, and Mutex instead of synchronized.
21 questions
JuniorTheoryVery commonWhen do you pick Dispatchers.Main, Dispatchers.IO or Dispatchers.Default?
When do you pick Dispatchers.Main, Dispatchers.IO or Dispatchers.Default?
A dispatcher decides which thread runs the coroutine. Main is the single UI thread — touch the interface only there. IO is a large elastic pool for blocking input/output such as files, sockets and database calls. Default is a CPU-bound pool sized to the number of cores.
Common mistakes
- ✗Running blocking input/output on
Defaultand starving the CPU-bound work - ✗Thinking
IOandDefaultdiffer only in scheduling priority - ✗Touching UI state from a background dispatcher
Follow-up questions
- →Why may
Dispatchers.IOhold far more threads than the machine has cores? - →What happens if a CPU-heavy loop runs on
Dispatchers.Main?
JuniorTheoryVery commonHow do launch and async differ in what they return and how they report failure?
How do launch and async differ in what they return and how they report failure?
launch returns a Job — fire-and-forget; if its body throws, the exception propagates to the parent immediately. async returns a Deferred<T> that carries a result, and it holds its exception until you call await(), which is where the failure is rethrown.
Common mistakes
- ✗Expecting an
asyncfailure to surface without anyone callingawait() - ✗Thinking
launchcan hand a value back to its caller - ✗Believing
asyncblocks the caller until its result is ready
Follow-up questions
- →Why does an
asyncinside asupervisorScopestill throw at the caller'sawait()? - →When is
launchthe right builder even though you also need a result?
JuniorTheoryVery commonWhat does a CoroutineScope give you, and what is structured concurrency?
What does a CoroutineScope give you, and what is structured concurrency?
A CoroutineScope owns a Job that becomes the parent of every coroutine launched in it. That is structured concurrency: children are tied to a lifetime, the scope waits for them, and cancelling the scope cancels them all — so no coroutine outlives its owner or leaks.
Common mistakes
- ✗Thinking a scope is only a grouping label with no lifetime of its own
- ✗Assuming a launched child survives after its scope has been cancelled
- ✗Believing
GlobalScopegives the same guarantees as an owned scope
Follow-up questions
- →How does
coroutineScope { }differ from constructing aCoroutineScopeobject? - →Why is
GlobalScopea leak inside an AndroidViewModel?
JuniorTheoryVery commonHow does a coroutine differ from a thread, and why can you run millions of them?
How does a coroutine differ from a thread, and why can you run millions of them?
A coroutine is not a thread: it is a suspendable computation the compiler turns into a state machine. Suspending frees the thread instead of blocking it, so a single thread runs many coroutines. Each one costs a small heap object rather than a megabyte-sized stack — hence millions.
Common mistakes
- ✗Calling a coroutine a lightweight thread and assuming it owns one
- ✗Thinking
suspendblocks or parks the current thread while it waits - ✗Believing every coroutine allocates its own call stack
Follow-up questions
- →What does the compiler generate to hold a
suspendfunction's local state? - →Why is a blocking call inside a coroutine still harmful?
JuniorTheoryCommonHow does Deferred<T> differ from a Future you have to block on?
How does Deferred<T> differ from a Future you have to block on?
Deferred<T> is a Job that also carries a result. You read it with the suspending await(), which frees the thread until the value is ready, whereas Future.get() blocks the caller. A Deferred is a child of its scope and is cancelled with it.
Common mistakes
- ✗Assuming
await()blocks the thread the wayFuture.get()does - ✗Thinking a
Deferredis detached from the scope that created it - ✗Believing
asyncis lazy and starts only at the firstawait()
Follow-up questions
- →What does
async(start = CoroutineStart.LAZY)change about that? - →Why do two
Deferreds awaited one after another still run in parallel?
MiddleTheoryCommonWhat lives in a CoroutineContext, and how does a child coroutine inherit it?
What lives in a CoroutineContext, and how does a child coroutine inherit it?
A CoroutineContext is an indexed set of elements — typically a Job, the dispatcher, a CoroutineName and a CoroutineExceptionHandler. A child inherits its parent's context but gets its own new Job, and anything passed to the builder overrides the inherited element with the same key.
Common mistakes
- ✗Thinking the child shares its parent's
Jobinstance rather than getting a new one - ✗Treating the context as a mutable map a child can write into
- ✗Assuming a builder argument is ignored once the parent has a context
Follow-up questions
- →Why does
+on two contexts keep only one element per key? - →What would break if a child reused its parent's
Jobinstead of a new one?
MiddleTheoryCommonWhy guard state in suspending code with a Mutex rather than with synchronized?
Why guard state in suspending code with a Mutex rather than with synchronized?
synchronized blocks the thread and must not be held across a suspension point: the coroutine may resume on another thread and release a monitor it never took. Mutex.withLock suspends instead. It is not re-entrant — nesting it deadlocks.
Common mistakes
- ✗Holding a
synchronizedblock across a suspending call - ✗Assuming
Mutexis re-entrant the way a JVM monitor is - ✗Thinking
Mutex.withLockblocks the underlying thread while it waits
Follow-up questions
- →When is a single-threaded dispatcher a better fix than a
Mutex? - →What does
Mutex.withLockguarantee if the coroutine is cancelled inside it?
MiddleTheoryCommonWhy does runBlocking belong in main and in tests but not in production coroutine code?
Why does runBlocking belong in main and in tests but not in production coroutine code?
runBlocking blocks the calling thread until its body and all its children finish — the bridge from blocking code into the coroutine world, which main and a test want. Inside coroutine code it wastes the thread suspension exists to free, and on Main it freezes the UI.
Common mistakes
- ✗Thinking
runBlockingsuspends instead of blocking its caller - ✗Using
runBlockinginside asuspendfunction to call another suspending function - ✗Calling
runBlockingon the UI thread to get a result quickly
Follow-up questions
- →What deadlock can nested
runBlockingcalls cause on a single-threaded dispatcher? - →Why is
runTestpreferred overrunBlockingin coroutine unit tests?
MiddleTheoryCommonWhat does the compiler do to a suspend function, and why does suspending not block?
What does the compiler do to a suspend function, and why does suspending not block?
The compiler rewrites a suspend function into a state machine and adds a hidden Continuation parameter — continuation-passing style. Each suspension point is a state: suspending saves that state, returns from the function and frees the thread, and the continuation resumes it later, possibly on another thread.
Common mistakes
- ✗Thinking
suspendblocks or parks the carrier thread while it waits - ✗Assuming a
suspendfunction always resumes on the thread that started it - ✗Believing the
suspendkeyword alone makes a blocking call non-blocking
Follow-up questions
- →Why may a
suspendfunction be called only from another one or from a builder? - →What does the
Continuationhold besides the point to resume at?
MiddleTheoryCommonWhy use withContext rather than launch to move a piece of work onto another dispatcher?
Why use withContext rather than launch to move a piece of work onto another dispatcher?
withContext creates no new coroutine: it suspends the current one, runs the block on the given dispatcher and resumes with its result, so it is sequential and returns a value. launch starts a concurrent child and returns a Job, not a result.
Common mistakes
- ✗Thinking
withContextstarts a new coroutine the waylaunchdoes - ✗Believing the dispatcher switch persists after the
withContextblock ends - ✗Reaching for
launchwhen the caller needs the value and must wait for it
Follow-up questions
- →Why does
withContext(Dispatchers.IO)inside asuspendfunction make it main-safe? - →What does
withContextdo when the target dispatcher equals the current one?
SeniorDesignCommonIn a pull request you find GlobalScope.launch in a dozen places — in a ViewModel to load a screen, in a repository to warm a cache, and in an Activity to send an analytics event. The author argues that it is simpler than threading a scope through the code and that the coroutines are short-lived anyway. Explain what you would flag in review: which guarantee GlobalScope gives up, what concretely goes wrong on a screen the user closes mid-load, and what you would ask for instead in each of those three places.
In a pull request you find GlobalScope.launch in a dozen places — in a ViewModel to load a screen, in a repository to warm a cache, and in an Activity to send an analytics event. The author argues that it is simpler than threading a scope through the code and that the coroutines are short-lived anyway. Explain what you would flag in review: which guarantee GlobalScope gives up, what concretely goes wrong on a screen the user closes mid-load, and what you would ask for instead in each of those three places.
GlobalScope is bound to no lifecycle, so its coroutines are never cancelled: a closed screen keeps loading, keeps its ViewModel reachable and may write into dead UI. Replace it with the owner's scope — viewModelScope, lifecycleScope, an injected app scope.
Common mistakes
- ✗Believing that short-lived work makes
GlobalScopeharmless - ✗Thinking a coroutine is cancelled when its enclosing object is garbage-collected
- ✗Confusing
GlobalScopewith a scope that merely lacks an exception handler
Follow-up questions
- →Why is an injected application scope acceptable in a repository but not in a ViewModel?
- →Which single legitimate use of
GlobalScopewould you still accept in review?
SeniorTheoryCommonWhy is structured concurrency safer than hand-launching work on the thread-pool API ExecutorService?
Why is structured concurrency safer than hand-launching work on the thread-pool API ExecutorService?
A submitted task is an orphan: nobody waits for it, cancelling the caller never stops it, and its failure sits unread in a Future. A scope makes each coroutine a child of its Job — the scope will not finish until every child does, cancellation propagates down to them, and a child's failure reaches the parent and cancels its siblings.
Common mistakes
- ✗Thinking
executor.shutdown()gives the same waiting guarantee that a scope gives its children - ✗Treating structured concurrency as a performance feature rather than a lifetime and error-propagation one
- ✗Expecting a failing child to stay contained instead of reaching the parent and cancelling its siblings
Follow-up questions
- →Which scope building block turns off the sibling-cancellation rule, and when do you want that?
- →Why does cancellation need cooperation — what actually makes a running coroutine stop?
MiddleDebuggingOccasionalWhy do these two async calls still take two seconds instead of one?
Why do these two async calls still take two seconds instead of one?
Awaiting on the spot serializes them: the first async is awaited before the second even starts, so the two second-long calls run back to back. Start both builders first, keep the Deferreds in variables, and await them afterwards.
Common mistakes
- ✗Thinking
asyncis lazy and only starts whenawait()is called - ✗Believing
await()blocks the thread rather than suspending the coroutine - ✗Assuming two children of one scope overlap no matter where you await them
Follow-up questions
- →What would
awaitAll(user, feed)change about this code? - →If
loadUser()throws, what happens to the still-runningloadFeed()?
MiddleCodeOccasionalMake a shared counter safe when a thousand coroutines increment it concurrently
Make a shared counter safe when a thousand coroutines increment it concurrently
count++ is a read-modify-write, so concurrent coroutines overwrite each other's updates. Guard it with mutex.withLock { count++ } — acquiring suspends rather than blocking a pool thread. An AtomicInteger is the cheaper lock-free alternative.
Common mistakes
- ✗Thinking
@Volatilemakescount++an atomic operation - ✗Assuming an
Intincrement is atomic on the JVM - ✗Holding a blocking
synchronizedlock across a suspension point
Follow-up questions
- →When would an
AtomicIntegerbe preferable to aMutexhere? - →How does
limitedParallelism(1)solve the same race without any lock?
MiddlePerformanceOccasionalWhy is Dispatchers.IO allowed far more threads than Dispatchers.Default?
Why is Dispatchers.IO allowed far more threads than Dispatchers.Default?
Default runs CPU-bound work, so it holds about one thread per core — more would only add context switching, not throughput. An IO thread sits blocked in a syscall instead of on the CPU, so IO grows elastically to far more threads (at least 64).
Common mistakes
- ✗Running a blocking call on
Defaultand starving the CPU-bound coroutines - ✗Thinking more threads always mean more CPU throughput
- ✗Believing
IOshould be sized to the core count likeDefault
Follow-up questions
- →What happens to CPU-bound work when a blocking call occupies a
Defaultthread? - →How would you bound one repository's parallelism without writing a custom dispatcher?
SeniorDesignOccasionalA legacy location SDK exposes only a callback API — requestLocation(onResult, onError) — and it returns a handle you can cancel. Your team wants the rest of the app to see a single suspend fun currentLocation(): Location that behaves like any other suspending call: it returns the value, throws on error, and stops the underlying SDK request when the calling coroutine is cancelled. Describe how you would build that wrapper, which coroutine primitive bridges the callback into a suspension, and how you would guarantee both that the continuation is resumed exactly once and that cancellation actually reaches the SDK.
A legacy location SDK exposes only a callback API — requestLocation(onResult, onError) — and it returns a handle you can cancel. Your team wants the rest of the app to see a single suspend fun currentLocation(): Location that behaves like any other suspending call: it returns the value, throws on error, and stops the underlying SDK request when the calling coroutine is cancelled. Describe how you would build that wrapper, which coroutine primitive bridges the callback into a suspension, and how you would guarantee both that the continuation is resumed exactly once and that cancellation actually reaches the SDK.
Bridge it with suspendCancellableCoroutine: give the SDK's callbacks a CancellableContinuation, resume it with the value or resumeWithException, and register invokeOnCancellation { handle.cancel() }. It may resume only once — ignore later callbacks.
Common mistakes
- ✗Using
suspendCoroutine, which cannot deliver cancellation to the SDK - ✗Resuming the continuation twice when the SDK fires both callbacks
- ✗Blocking a thread with
runBlockingjust to wait for the callback
Follow-up questions
- →What does
invokeOnCancellationguarantee about the thread its block runs on? - →How would you wrap a callback that fires repeatedly rather than once?
SeniorDebuggingOccasionalWhy does this viewModelScope.launch still freeze the interface?
Why does this viewModelScope.launch still freeze the interface?
viewModelScope dispatches to Dispatchers.Main, and launch alone does not move work off it. Both calls block rather than suspend, so they hold the UI thread. Wrap the query in withContext(Dispatchers.IO) and the parse in withContext(Dispatchers.Default).
Common mistakes
- ✗Assuming any
launchautomatically runs its body off the main thread - ✗Thinking that marking a function
suspendmakes a blocking call non-blocking - ✗Confusing the default dispatcher of
viewModelScopewithDispatchers.Default
Follow-up questions
- →Why would
withContext(Dispatchers.IO)be the wrong dispatcher forparseJson? - →How do you make a repository function main-safe so its callers need no
withContext?
SeniorDebuggingOccasionalWhy does this repository hang forever on the first call to user()?
Why does this repository hang forever on the first call to user()?
load is called while already on dbDispatcher, and runBlocking blocks that one thread. The withContext(dbDispatcher) inside then queues work for the very thread that is parked, so neither side can progress — a deadlock. Make load a suspend fun, call db.load(id) directly and delete the runBlocking.
Common mistakes
- ✗Using
runBlockingto call asuspendfunction from ordinary code that already runs inside a coroutine - ✗Thinking
runBlockingsuspends its caller instead of blocking the caller's thread - ✗Assuming a
withContextonto the dispatcher you are already on is always a free no-op
Follow-up questions
- →Why does the same nesting deadlock on
Dispatchers.Mainbut often survive onDispatchers.IO? - →How would you make this deadlock deterministic in a unit test?
SeniorDesignOccasionalA repository calls a flaky remote API that intermittently returns HTTP 503 and sometimes hangs. Product wants every call retried up to four times with exponential backoff plus jitter, giving up with the original error afterwards. The retry must never retry a 400-class client error, must honour the caller's cancellation immediately — including while it is waiting between attempts — and must bound the total time one call may take. Describe how you would implement this retry helper as a suspending function, which coroutine primitives you would use for the waiting and for the time bound, and how you would keep it composable so that any repository call can simply be wrapped in it.
A repository calls a flaky remote API that intermittently returns HTTP 503 and sometimes hangs. Product wants every call retried up to four times with exponential backoff plus jitter, giving up with the original error afterwards. The retry must never retry a 400-class client error, must honour the caller's cancellation immediately — including while it is waiting between attempts — and must bound the total time one call may take. Describe how you would implement this retry helper as a suspending function, which coroutine primitives you would use for the waiting and for the time bound, and how you would keep it composable so that any repository call can simply be wrapped in it.
Write a suspend helper taking the call as a lambda: loop the attempts with a cancellable delay(backoff) between them, multiplying it and adding jitter. Rethrow a non-retryable error at once, and the last error when the attempts run out. Bound the call with withTimeout.
Common mistakes
- ✗Blocking the thread with
Thread.sleepin the pause between attempts - ✗Retrying client errors that can never succeed on a repeat
- ✗Detaching the retry from the caller's scope, so cancellation is ignored
Follow-up questions
- →Why does
delayhonour cancellation whileThread.sleepdoes not? - →How does
withTimeoutdiffer fromwithTimeoutOrNullfor this helper?
SeniorTheoryOccasionalHow do Kotlin coroutines differ from Java's JVM-scheduled virtual threads in model and cost?
How do Kotlin coroutines differ from Java's JVM-scheduled virtual threads in model and cost?
A coroutine is a compiler construct: suspend becomes a state machine with a Continuation, so suspending is a return rather than a park — at the price of colouring the whole call chain. A virtual thread is a runtime construct: existing blocking code unmounts from its carrier unchanged. Both cost a heap object, not an OS stack, but a native frame still pins a virtual thread.
Common mistakes
- ✗Calling a virtual thread a coroutine under another name, missing that one is a compiler rewrite and the other a runtime unmount
- ✗Believing nothing pins a virtual thread since JDK 24 lifted the
synchronizedpin — native and foreign frames still do - ✗Presenting Java's structured concurrency as final; it is still a preview API, unlike scoped values
Follow-up questions
- →Why does a blocking database-driver
JDBCcall still cost you a thread onDispatchers.IO? - →Which parts of a call chain does
suspendforce you to change, and what does that buy you?
SeniorDesignRareA repository fans out many concurrent calls to a third-party API that allows at most five requests in flight and rejects the rest with HTTP 429. Its callers are ordinary suspending functions all over the app, and any of them may be cancelled at any moment. Design a limiter inside the repository that admits at most five concurrent requests, makes the rest wait rather than fail, never blocks a pool thread while waiting, and gives the permit back correctly when a caller is cancelled — whether it was still waiting or already in flight.
A repository fans out many concurrent calls to a third-party API that allows at most five requests in flight and rejects the rest with HTTP 429. Its callers are ordinary suspending functions all over the app, and any of them may be cancelled at any moment. Design a limiter inside the repository that admits at most five concurrent requests, makes the rest wait rather than fail, never blocks a pool thread while waiting, and gives the permit back correctly when a caller is cancelled — whether it was still waiting or already in flight.
Guard the API with a Semaphore(5) and wrap each call in semaphore.withPermit { … }. Acquiring suspends when all five are taken, so no thread is blocked, and withPermit releases in a finally, so a cancelled caller — waiting or in flight — gives its permit back.
Common mistakes
- ✗Spinning or blocking a thread while waiting for free capacity
- ✗Releasing the permit outside a
finally, so cancellation leaks it - ✗Confusing a
Mutex(one permit) with the five-permit case
Follow-up questions
- →How does
limitedParallelism(5)compare with aSemaphorefor this job? - →What would you add so that bursts are smoothed over time, not merely capped in flight?