Coroutines cheat sheet
The 18 Coroutines interview answers in 30 seconds each, plus quick facts. Printable, free.
One page per topic: print it or save it as PDF from your browser's print menu. Practise these questions
Rules of thumb
- The function that does the work picks the dispatcher with withContext.
- Never catch CancellationException without rethrowing it.
- viewModelScope survives rotation; lifecycleScope does not.
- Don't use GlobalScope: inject an app scope, or use WorkManager.
- Supervision changes cancellation, not error handling.
What happens to viewModelScope work on rotation?
Rotation keeps the request running. The Activity is destroyed and recreated, but its ViewModel survives the configuration change, so viewModelScope is not cancelled and the new screen gets the result. Pressing Back finishes the Activity for good: the ViewModel is cleared and viewModelScope cancels the call.
Coroutines vs threads
A coroutine is a suspendable unit of work that runs on threads but does not own one. When it suspends, it gives its thread back so other coroutines can run there. That makes coroutines cheap enough to start thousands of them, while each thread costs memory and kernel scheduling.
What does suspend mean?
suspend marks a function that may pause without blocking its thread and resume later. It can only be called from a coroutine or another suspend function. It does not move work to a background thread: a suspend function that does CPU work runs on whatever dispatcher called it.
suspend fun awaitLocation(client: LocationClient): Location =
suspendCancellableCoroutine { cont ->
val callback = object : LocationCallback {
override fun onResult(location: Location) { cont.resume(location) }
override fun onError(e: Exception) { cont.resumeWithException(e) }
}
client.request(callback)
cont.invokeOnCancellation { client.cancel(callback) }
}launch vs async
launch starts a coroutine for its side effect and returns a Job. async starts one that produces a value and returns a Deferred you await. Use async only when you need results from parallel calls; two async calls awaited later run at the same time, while two sequential suspend calls do not.
Structured concurrency
Structured concurrency means every coroutine has a parent scope, and a scope does not finish until all its children finish. coroutineScope { } suspends until every child completes, cancels the rest if one fails, and rethrows the failure. Work can't leak past the function that started it.
data class Page(val details: String, val reviews: List<String>)
interface PageApi {
suspend fun details(): String
suspend fun reviews(): List<String>
}CoroutineScope vs CoroutineContext
A CoroutineContext is a set of elements that configure a coroutine: its Job, dispatcher, name and exception handler. A CoroutineScope is just an object that holds a context so you can launch children into it. The scope defines lifetime through its Job; the context is the configuration every child inherits.
Choosing a dispatcher
Main runs on the UI thread and is for touching views and state. Default is a pool sized to the CPU cores, for CPU-heavy work like parsing or sorting. IO shares threads with Default but allows many more at once (64 by default), because blocking file and network calls mostly wait. Unconfined is for special cases only.
withContext and main-safety
withContext runs a block on another context, usually another dispatcher, suspends the caller until the block finishes, and returns its result. A function is main-safe when it can be called from the main thread without freezing it, because it moves its own blocking or heavy work elsewhere with withContext.
interface LegacySdk {
fun downloadBlocking(): String
}Cooperative cancellation
Cancellation in coroutines is cooperative. Cancelling a job sets a flag and makes the next suspension point throw CancellationException. Code that never suspends, like a tight CPU loop, never sees it. Check isActive, call ensureActive() or yield() inside long loops so the work can stop.
suspend fun checksum(chunks: List<ByteArray>): Long = withContext(Dispatchers.Default) {
var sum = 0L
for (chunk in chunks) {
ensureActive()
for (b in chunk) sum += b
}
sum
}Don't swallow CancellationException
Cancellation is delivered as a CancellationException. A broad catch (e: Exception) catches it too, so a cancelled coroutine carries on as if nothing happened, keeps working after the screen is gone, and can write stale data. Catch the specific exceptions you expect, or rethrow CancellationException.
suspend inline fun <T> safeCall(block: () -> T): Result<T> =
try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}Job vs SupervisorJob
With a regular Job, a child that fails cancels its parent, which cancels every other child, and the exception goes up the tree. With a SupervisorJob, a child's failure stays with that child: siblings keep running and the parent is not cancelled. The failing child's exception still has to be handled somewhere.
val handler = CoroutineExceptionHandler { _, e ->
log("Uncaught: ${e.message}")
}Where CoroutineExceptionHandler works
A CoroutineExceptionHandler only handles uncaught exceptions in root coroutines, or in direct children of a supervisor, started with launch. Installed on a nested child, it is ignored, because the child passes the failure up to its parent. It never catches exceptions from async; those surface at await.
Loading several things in parallel
Inside one suspend function, wrap the calls in coroutineScope and start each with async, then await all three. They run at the same time, so the screen waits for the slowest call. If any call fails, the others are cancelled and the error is thrown, which suits all-or-nothing screens. For independent sections, use supervisorScope and catch per call.
suspend fun loadAll(ids: List<String>): List<User> = coroutineScope {
val permits = Semaphore(4)
ids.map { id ->
async { permits.withPermit { api.user(id) } }
}.awaitAll()
}withTimeout vs withTimeoutOrNull
withTimeout(ms) { } cancels the block when the time runs out and throws TimeoutCancellationException. withTimeoutOrNull returns null instead of throwing, which is usually easier to handle. Both rely on cancellation, so a block that blocks the thread without suspending will overrun the timeout.
Shared mutable state
count++ is a read, an add and a write. On a multi-thread dispatcher, two coroutines can read the same value and both write it back, so increments are lost. Fix it by making the update atomic (AtomicInteger, MutableStateFlow.update), by guarding it with a Mutex, or by confining the state to one thread.
Collecting safely from the UI
lifecycleScope.launch { flow.collect { } } keeps collecting while the app is in the background, wasting work and possibly updating views that aren't shown. repeatOnLifecycle(Lifecycle.State.STARTED) starts collection when the screen is visible and cancels it when it stops, then restarts it on return. In Compose, collectAsStateWithLifecycle does the same.
class MapViewModel : ViewModel() {
val locations: Flow<String> = flowOf("52.5, 13.4")
}Why not GlobalScope?
GlobalScope has no Job, so nothing can cancel its coroutines, errors aren't tied to any owner, and tests can't wait for them. Work leaks past the screen that started it. For short work that must outlive a screen, inject an application-scoped CoroutineScope with a SupervisorJob. For work that must survive process death, use WorkManager.
@Module
@InstallIn(SingletonComponent::class)
object ScopeModule {
@Provides @Singleton
fun appScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}runBlocking on Android
runBlocking starts a coroutine and blocks the current thread until it finishes. It bridges blocking code to coroutines in main functions and some legacy callbacks. On the Android main thread it freezes the UI for as long as the work takes, and a suspend call that needs the main thread to resume can deadlock it.
Quick facts
- Dispatchers.IO parallelism
- Dispatchers.IO allows up to 64 threads (or the number of cores, if larger) and shares them with Dispatchers.Default.
- viewModelScope context
- viewModelScope is SupervisorJob() + Dispatchers.Main.immediate, and it is cancelled when the ViewModel is cleared, just before onCleared() runs.
- CoroutineStart.LAZY
- async(start = CoroutineStart.LAZY) doesn't run until you call start() or await().
- yield()
- yield() checks for cancellation and lets other coroutines on the same dispatcher run.
- NonCancellable
- withContext(NonCancellable) lets cleanup in a finally block suspend after the coroutine was cancelled.
- runInterruptible
- runInterruptible runs blocking code and interrupts its thread if the coroutine is cancelled.