You know launch { ... } puts work on a dispatcher and returns a Job. You can read your own code. But when a supervisorScope swallows a child failure and your screen survives, or when cancel() finishes before the child actually stops, the mental model you built from tutorials stops being enough. You start patching symptoms: wrapping everything in try/catch, reaching for GlobalScope to "just get it running," adding delay(0) to dodge a race you cannot explain. Each patch works until the next surprise.
The fix is not more APIs. It is one consistent picture of how CoroutineContext, Job, CoroutineScope, and Dispatcher compose, and what the cancellation and exception rules mean for the code you write every day.
In this article, you'll explore how coroutines suspend and resume, how CoroutineContext, Job, CoroutineScope, and Dispatcher compose into one mental model, and the cancellation and exception rules that make structured concurrency safe.
TL;DR
- A coroutine is a suspendable computation. A small state machine that runs on a thread but never blocks it.
suspendis the compiler feature that makes this possible. - Four builders cover almost everything:
runBlocking,launch,async, andwithContext. Pick the one that matches your intent (bridge, fire and forget, compute a value, switch dispatcher). - Every coroutine lives inside a
CoroutineContextwith aJob, aDispatcher, and optionally aCoroutineNameorCoroutineExceptionHandler. Contexts are merged with+. Children inherit from parents. - Structured concurrency, the rule that parents wait for children and cancel them on failure, is the whole reason coroutines are safer than raw threads. Respect the
CoroutineScopeboundary and most concurrency bugs disappear.
What a coroutine actually is
The simplest correct sentence you can write about coroutines is this: a coroutine is an instance of a suspendable computation. It is not a thread, and it is not a green thread or fiber. It is a small object that represents "some work to do," plus a built in ability to pause mid execution and resume later.
A coroutine always runs on a thread. When you launch work, the Kotlin runtime does not create a new OS thread. It creates a state machine object on the heap and schedules it on an existing thread managed by a Dispatcher. When the coroutine hits a suspension point, for example a call to delay or a suspending network client, it saves its state into a Continuation and returns control of the thread. Another coroutine can then run on that same thread. When the awaited operation completes, the runtime calls continuation.resumeWith(...) and the coroutine picks up from where it left off, possibly on a completely different thread.
That is the whole trick. Here is what it looks like in practice:
import kotlinx.coroutines.*
suspend fun fetchGreeting(): String {
delay(500L)
return "Hello from a coroutine!"
}
fun main() = runBlocking {
println("Main on: ${Thread.currentThread().name}")
val greeting = fetchGreeting()
println(greeting)
}The delay(500L) above is the important bit. If fetchGreeting were a regular function calling Thread.sleep(500L), the thread would be frozen for half a second. With delay, the coroutine suspends, the thread is handed back to the dispatcher, and when the timer fires the coroutine resumes. Same textual syntax, fundamentally different runtime behavior.
Why suspend is language level
suspend is a keyword that only Kotlin understands. The JVM does not. When the compiler sees suspend fun fetchGreeting(): String, it rewrites the signature to append a Continuation<String> parameter and rewrites the body into a state machine. Each suspension point becomes a labeled state that can return a special COROUTINE_SUSPENDED marker. When the asynchronous operation completes, the runtime calls resumeWith on the Continuation, which re enters the state machine at the correct label.
The full compiler walkthrough (decompiled bytecode, the Continuation interface, how variables survive across suspension) is covered in the dedicated deep dive at /en/guides/suspend-function-internals. For the rest of this piece, the important takeaway is only this: suspend functions are regular functions that can pause, and the pause is implemented by passing a continuation callback around.
The four coroutine builders
Every coroutine comes into existence through a builder. A function that takes a suspending lambda, creates a coroutine object, starts it, and returns some kind of handle. In day to day code, four builders do almost everything:
| Builder | Returns | Blocks the thread? | Typical use |
|---|---|---|---|
runBlocking | T | Yes | Bridge from regular code into the coroutine world (main function, tests) |
launch | Job | No | Fire and forget side effects |
async | Deferred<T> | No | Concurrent computations whose results you want to combine |
withContext | T | No (suspends) | Switch dispatcher or add context elements for a block |
runBlocking, the bridge
runBlocking is the only one of the four that actually blocks the current thread. It runs a coroutine on the calling thread and does not return until that coroutine and all of its children complete. You use it in two places: the main function of a JVM app you want to write coroutine first, and unit tests that predate runTest.
fun main() = runBlocking {
println("Starting")
launch {
delay(500L)
println("Background work done")
}
println("Main work done")
// runBlocking waits for the launched child before returning
}On Android UI code, runBlocking is a foot gun. It will freeze the main thread. Keep it out of production code paths that run on the UI thread.
launch, fire and forget
launch starts a coroutine that performs a side effect and returns a Job. A handle you can use to cancel() or join(), but not to read a result. It is the right tool for "I want this work to happen, and I do not care about a return value."
fun updateUserProfile(name: String) {
viewModelScope.launch { // returns a Job
userRepository.save(name)
}
// the caller returns immediately; the launch block runs concurrently
}If an uncaught exception escapes a launch block, it propagates up through the parent Job immediately. That is usually the behavior you want for side effecting work. A failure should not silently disappear.
async, compute a value in parallel
async is for tasks that produce a result. It returns a Deferred<T> (which is a Job plus a slot for a value), and you retrieve the value with the suspending await() call. async is how you run independent work in parallel:
suspend fun loadUserDashboard(userId: String): Dashboard = coroutineScope {
val userDeferred = async { api.fetchUser(userId) }
val postsDeferred = async { api.fetchRecentPosts(userId) }
val statsDeferred = async { api.fetchStats(userId) }
Dashboard(
user = userDeferred.await(),
posts = postsDeferred.await(),
stats = statsDeferred.await()
)
}The three network calls execute concurrently, so the dashboard is ready roughly when the slowest of them finishes, not the sum of all three. Exceptions inside async are stored in the Deferred and rethrown at the .await() call site, which means failures in one parallel branch do not crash the scope until you actually ask for the result.
withContext, switch dispatcher (or context) for a block
withContext is the builder you will reach for most often. It suspends the current coroutine, runs the block with a modified CoroutineContext (usually a different dispatcher), and returns the result:
class UserRepository(private val api: UserApi, private val db: UserDao) {
suspend fun loadUser(id: String): User = withContext(Dispatchers.IO) {
val cached = db.findById(id)
cached ?: api.fetchUser(id).also { db.insert(it) }
}
}Unlike launch and async, withContext does not create an independent child job you later join. It is a direct, sequential call that happens to run on a different thread. It is the idiomatic way to offload blocking I/O or CPU bound work from the main thread.
CoroutineContext, the environment
Every coroutine carries a CoroutineContext: an immutable, map like collection of Elements that configure how and where the coroutine runs. Think of it as the "environment variables" for a coroutine. The four most common elements are:
Job: the coroutine's handle and lifecycle state.CoroutineDispatcher: the thread or thread pool it runs on.CoroutineName: a debugging label, visible in thread dumps and logs when-Dkotlinx.coroutines.debugis set.CoroutineExceptionHandler: a fallback for uncaught exceptions.
You build a context by combining elements with the + operator:
import kotlinx.coroutines.*
fun main() = runBlocking {
val ctx = Dispatchers.IO + CoroutineName("DataLoader") + SupervisorJob()
launch(ctx) {
val name = coroutineContext[CoroutineName]?.name
val thread = Thread.currentThread().name
println("Running as '$name' on $thread")
}
}Under the hood, CoroutineContext is a small interface with get, plus, and minusKey operators. Each Element is itself a CoroutineContext, a one element context, which is why Dispatchers.IO + CoroutineName("x") just works. The plus operator implements a "right side wins" merge: if both sides have a Job, the right hand Job replaces the left hand one. You retrieve elements by their companion Key:
suspend fun logCurrent() {
val job = coroutineContext[Job]
val dispatcher = coroutineContext[ContinuationInterceptor]
val name = coroutineContext[CoroutineName]?.name ?: "unnamed"
println("[$name] job=$job dispatcher=$dispatcher")
}Inheritance in child coroutines
When you launch inside another coroutine, the child inherits the parent's context and merges in anything you pass as the context argument. One exception: the child always gets a new Job, which becomes a child of the parent's Job. That parent child link is the glue of structured concurrency, and it is baked into the internals of every builder.
fun main() = runBlocking(CoroutineName("outer")) {
// parent has CoroutineName("outer") and a Job
launch(Dispatchers.IO) {
// child inherits "outer" (not overridden), switches to Dispatchers.IO,
// and has a new Job whose parent is the runBlocking Job
val name = coroutineContext[CoroutineName]?.name
println("child name inherited: $name on ${Thread.currentThread().name}")
}
}Job and the hierarchy
A Job is a cancellable unit of work with a lifecycle. Every coroutine has exactly one, and it is what lets you talk about "that coroutine" as a thing you can observe and control. Its public surface is small: isActive, isCompleted, isCancelled, cancel(), join(), invokeOnCompletion { }. But it encapsulates a surprisingly rich state machine.
The states
- New: the initial state for a lazily started coroutine. Not yet scheduled.
- Active: running or ready to run. This is the default initial state for eagerly started builders.
- Completing: the coroutine's own block has finished, but the job is waiting for its children to finish before it can transition to
Completed. - Cancelling: a cancel was requested (or a failure happened), and the job is propagating cancellation to its children and waiting for them to finish.
- Completed / Cancelled: terminal states.
The Completing state is the one most developers miss. It is the exact reason coroutineScope { } and all the builders "wait for their children" without you writing any extra code. A parent cannot become Completed while any child is still Active.
Parent and child
When a coroutine is created, its Job calls parentJob.attachChild(this) and stores the returned handle so it can detach on completion. This single link gives structured concurrency two of its most important properties:
- Downward cancellation: cancel a parent
Joband every child is cancelled recursively. - Upward failure propagation: a child that fails with a non
CancellationExceptioncallsparent.childCancelled(cause). The default implementation cancels the parent, which then cancels every sibling.
import kotlinx.coroutines.*
fun main() = runBlocking {
val parent = launch {
launch {
delay(500L)
println("child A completed")
}
launch {
delay(200L)
error("child B blew up")
}
}
parent.join()
println("parent cancelled: ${parent.isCancelled}")
}Child B throws at 200 ms. That failure cancels the parent, which cancels child A before it can print. This "one failure stops all related work" behavior is usually what you want. A half finished result is often worse than no result. But not always.
Why SupervisorJob inverts the rule
In a regular Job, a failed child actively tells its parent to cancel. The parent obliges, and every sibling goes down with it. That is correct for work that forms a single transaction: if one leg of a checkout flow fails, the other legs should not keep spending money.
But plenty of work is not transactional. A Home screen loads a banner, a feed, and a widget row in parallel. If the banner fetch times out, you still want the feed. SupervisorJob is the answer. It is a one line specialization of JobImpl that overrides childCancelled to return false. It declines to propagate child failure upward. The failed child is marked cancelled, but the parent stays active and siblings keep running.
import kotlinx.coroutines.*
fun main() = runBlocking {
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val first = scope.launch {
delay(100L)
error("banner failed")
}
val second = scope.launch {
delay(300L)
println("feed loaded successfully")
}
joinAll(first, second)
}That is the entire implementation. Its siblings keep running and the supervisor scope stays alive.
supervisorScope { } is the block level equivalent of SupervisorJob() for when you need supervisor semantics for a local chunk of code rather than for a long lived scope. It is idiomatic inside a normal coroutineScope { } when a specific subgroup of tasks should be fault tolerant.
CoroutineScope, the structured concurrency boundary
A CoroutineScope is a tiny interface. Literally one property:
public interface CoroutineScope {
public val coroutineContext: CoroutineContext
}That is it. A scope holds a CoroutineContext (which, by convention, contains a Job) and nothing else. The builders (launch, async, produce, actor) are all extension functions on CoroutineScope, which is how they find the parent Job to attach to.
What makes a scope useful is that you can cancel it. Cancelling a scope's Job cancels every coroutine launched from it. That is the structured concurrency boundary: code outside the scope cannot reach in, and work inside the scope cannot outlive it.
Custom scopes tied to an owner
When you own a class with a lifecycle (a repository, a feature controller, a long running service), create a scope and cancel it in the class's teardown hook:
class SyncService(
private val api: SyncApi
) {
private val scope = CoroutineScope(
Dispatchers.IO + SupervisorJob() + CoroutineName("SyncService")
)
fun start() {
scope.launch {
while (isActive) {
try {
api.pullUpdates()
} catch (t: Throwable) {
if (t is CancellationException) throw t
// log and keep going
}
delay(30_000L)
}
}
}
fun shutdown() {
scope.cancel() // cancels every coroutine launched by this scope
}
}The SupervisorJob here is load bearing: if one iteration of the sync loop fails unexpectedly, it should not kill the scope. The isActive check inside the loop cooperates with cancellation, which you will see more of shortly.
coroutineScope { } and supervisorScope { }
For localized parallelism, you almost never need a long lived scope. The two suspending helpers coroutineScope and supervisorScope create a temporary scope, run your block, and return only after every child has finished:
suspend fun loadPage(userId: String): Page = coroutineScope {
val profile = async { api.fetchProfile(userId) }
val feed = async { api.fetchFeed(userId) }
Page(profile.await(), feed.await())
}coroutineScope { } inherits the caller's context, creates a new Job underneath it, runs the block, and rethrows if anything fails, cancelling siblings in the process. supervisorScope { } does the same but isolates child failures. These two functions are the closest Kotlin gets to "explicit parallelism," and they are the first thing to reach for whenever you need to fan out work.
GlobalScope, why to avoid it
GlobalScope is a singleton with an EmptyCoroutineContext. It has no parent Job, no dispatcher, and no lifecycle. Coroutines launched in it are completely detached: they cannot be cancelled by anyone, they leak if they hold references to lifecycle bound objects, they skip your TestDispatcher, and they make your code awkward to test. In years of writing Kotlin, almost no use of GlobalScope in application code would not have been better served by a custom, owner bound CoroutineScope. The API is marked @DelicateCoroutinesApi for a reason.
Practical Kotlin Deep Dive
492 pages, 70 deep-dive topics, from coroutines to compiler internals. By Jaewoong Eum, Google Developer Expert.
Dispatchers
A CoroutineDispatcher is a CoroutineContext.Element that controls which thread (or pool of threads) a coroutine resumes on. The standard library ships four:
Dispatchers.Main: the platform UI thread (Android main looper, JavaFX EDT, Swing EDT).Dispatchers.Main.immediateis a variant that avoids a dispatch hop when you are already on the main thread. Right for anything that touches UI state.Dispatchers.Default: a work stealing pool sized to the number of CPU cores. Use it for CPU bound work: parsing, encoding, compression, image manipulation.Dispatchers.IO: an elastic pool (default cap: 64 threads, plus CPU cores) that shares its underlying scheduler withDefaultbut tolerates blocking calls. Use it for network, disk, and JDBC.Dispatchers.Unconfined: runs on the caller's thread until the first suspension, then on whatever thread resumes it. Useful inside library internals. Avoid it in application code.
import kotlinx.coroutines.*
suspend fun compressAndUpload(path: String) {
val compressed = withContext(Dispatchers.Default) {
compress(path) // CPU bound
}
withContext(Dispatchers.IO) {
upload(compressed) // blocking network call
}
}withContext is almost always the right way to switch dispatchers within a suspend function. It communicates intent clearly ("run this chunk somewhere specific") without introducing a new coroutine hierarchy.
Bounding concurrency with limitedParallelism
By default, Dispatchers.IO allows up to 64 concurrent blocking operations. For hot paths (a connection pool, a rate limited API) you often want fewer. limitedParallelism(n) returns a dispatcher that shares the underlying thread pool but caps how many coroutines run concurrently:
val limitedIo = Dispatchers.IO.limitedParallelism(8)
suspend fun writeBatch(records: List<Record>) = withContext(limitedIo) {
records.forEach { db.insert(it) }
}newSingleThreadContext("name") creates a dedicated thread. Convenient in a pinch but expensive. Prefer limitedParallelism(1) on an existing dispatcher when you just need sequential execution.
Cancellation, cooperatively
Coroutine cancellation is cooperative. Calling job.cancel() does not interrupt the thread. It sets the job's state to Cancelling and relies on the coroutine code itself to notice. All suspending functions from kotlinx.coroutines cooperate automatically: they throw CancellationException at their suspension points when the job has been cancelled.
The only time cancellation fails to propagate is when your code suspends nothing and polls nothing. Tight CPU loops are the classic offender:
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis
fun main() = runBlocking {
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var next = 1L
while (next < Long.MAX_VALUE) {
// no suspension here, cancellation cannot break in
if (System.currentTimeMillis() >= startTime + 5000L) break
next++
}
}
delay(100L)
println("Cancelling...")
job.cancelAndJoin()
println("Done")
}The job ignores the cancel and keeps spinning for five seconds. To fix it, make the loop cooperate:
val job = launch(Dispatchers.Default) {
var next = 1L
while (isActive && next < Long.MAX_VALUE) {
next++
}
}isActive is a property on CoroutineScope (and on coroutineContext[Job]). ensureActive() is the throwing equivalent: it throws CancellationException if the job is no longer active. yield() additionally gives other coroutines on the dispatcher a chance to run.
CancellationException is special
CancellationException is treated differently from every other Throwable. When a child throws it, the parent does not fail. Kotlin reads it as "I was cancelled, this is expected." That is why you must never swallow CancellationException in a generic catch (t: Throwable) block without rethrowing it:
suspend fun robustFetch(): Data {
return try {
api.fetch()
} catch (c: CancellationException) {
throw c // never swallow cancellation
} catch (e: Exception) {
Data.empty()
}
}Swallowing cancellation is one of the most common real world coroutine bugs. The coroutine keeps running after its parent is cancelled, and the bug shows up as a phantom network request or a crash on a destroyed UI.
withContext(NonCancellable), cleanup that must finish
Sometimes you need a tiny block of code to run even if the coroutine is being cancelled: flushing a buffer, releasing a resource, emitting a final log line. NonCancellable is a Job implementation that is always active and cannot be cancelled. Wrapping a block in withContext(NonCancellable) temporarily shields it:
suspend fun withSession(block: suspend (Session) -> Unit) {
val session = Session.open()
try {
block(session)
} finally {
withContext(NonCancellable) {
session.flush()
session.close()
}
}
}Use this sparingly. The point of structured concurrency is that cancellation reliably tears things down. NonCancellable is the emergency escape hatch, not a default.
Exception handling
Exceptions in coroutines follow two simple rules, plus one asterisk.
Rule 1. Inside a regular (non supervisor) Job, an uncaught exception in a child cancels the parent, which cancels all other children. The exception is stored on the parent's Job and ultimately surfaces via whatever is waiting for it: a join(), an await(), or the invokeOnCompletion callback.
Rule 2. Inside a SupervisorJob or supervisorScope, child failures are contained. The failed coroutine is cancelled. Its siblings are not.
The asterisk. async does not rethrow at the failure site. It rethrows at the matching await(). If you never call await(), the exception may appear to "disappear" until the scope completes.
CoroutineExceptionHandler for root coroutines
CoroutineExceptionHandler is the last line of defense for uncaught exceptions in root coroutines: coroutines launched directly in a scope with no parent coroutine (or children of a SupervisorJob, which treats each child as a de facto root for exception purposes). It is invoked only for exceptions that nothing else will handle:
import kotlinx.coroutines.*
val errorHandler = CoroutineExceptionHandler { _, exception ->
// ship to crash reporter, log, etc.
println("Uncaught: ${exception::class.simpleName}: ${exception.message}")
}
fun main() = runBlocking {
val scope = CoroutineScope(SupervisorJob() + errorHandler)
scope.launch {
throw IllegalStateException("boom")
}.join()
println("scope still alive? ${scope.coroutineContext[Job]?.isActive}")
}A handler installed in an async coroutine is not invoked. async stores its exception for await() to rethrow. A handler installed on a non root launch is also ignored. The exception walks up to the root regardless. In practice, attach CoroutineExceptionHandler to the scope itself (or to the top level launch), not to every child.
A pattern for background work
For a long running background scope, combine SupervisorJob (so individual task failures are survivable) with a CoroutineExceptionHandler (so you always see them):
class BackgroundScope(tag: String) {
private val handler = CoroutineExceptionHandler { _, t ->
logger.error("[$tag] uncaught", t)
}
val scope = CoroutineScope(
Dispatchers.Default + SupervisorJob() + handler + CoroutineName(tag)
)
}Cancelling scope cancels every coroutine inside. Each launch within it is independent. Every escaped exception is logged exactly once. That is the whole playbook.
A worked example
Now put everything together. The task: given a list of user IDs, fetch each user's profile and that user's recent posts in parallel, retrying transient failures up to three times, and respecting cancellation the whole way. The result is a list of UserWithPosts, or an exception if everything fails.
Start with the data model and a small fake API that randomly fails. This is what stands in for your real network client.
import kotlinx.coroutines.*
import kotlin.random.Random
data class User(val id: String, val name: String)
data class Post(val id: String, val userId: String, val title: String)
data class UserWithPosts(val user: User, val posts: List<Post>)
class UserApi {
suspend fun fetchUser(id: String): User {
simulateLatency()
return User(id, "user-$id")
}
suspend fun fetchPosts(id: String): List<Post> {
simulateLatency()
return List(3) { Post("$id-p$it", id, "Post $it by $id") }
}
private suspend fun simulateLatency() {
delay(Random.nextLong(50, 200))
if (Random.nextInt(5) == 0) error("transient network failure")
}
}Next, the retry helper. This is the part most developers get subtly wrong. Notice the first catch clause. CancellationException is re thrown immediately so cancellation can never be mistaken for a retryable failure. Only the generic Throwable branch backs off and loops.
suspend fun <T> retry(
times: Int = 3,
initialDelay: Long = 100L,
block: suspend () -> T
): T {
var currentDelay = initialDelay
repeat(times - 1) {
try {
return block()
} catch (c: CancellationException) {
throw c // never swallow cancellation
} catch (_: Throwable) {
delay(currentDelay)
currentDelay *= 2
}
}
return block() // final attempt, let it throw
}Now fan out the work. The outer coroutineScope is the structured concurrency boundary. Nothing returns from loadUsersWithPosts until every launched async has settled. For each user, two more inner async calls run the profile fetch and the posts fetch in parallel. That is two levels of concurrency: users against each other, and within each user the two resources against each other.
suspend fun loadUsersWithPosts(
api: UserApi,
ids: List<String>
): List<UserWithPosts> = coroutineScope {
ids.map { id ->
async(Dispatchers.IO) {
val userDeferred = async { retry { api.fetchUser(id) } }
val postsDeferred = async { retry { api.fetchPosts(id) } }
UserWithPosts(userDeferred.await(), postsDeferred.await())
}
}.awaitAll()
}Finally, the entry point. awaitAll() waits for every user and surfaces the first exception. Because the parent is a regular Job (not a supervisor), that first exception cancels the sibling asyncs too. No wasted work on results you are going to throw away.
fun main() = runBlocking {
val api = UserApi()
val ids = (1..10).map { "u$it" }
val results = try {
loadUsersWithPosts(api, ids)
} catch (e: Exception) {
println("Failed after retries: ${e.message}")
emptyList()
}
results.forEach { println("${it.user.name} -> ${it.posts.size} posts") }
}Swap in a real API client and you have a production shaped piece of code in about 40 lines. Every concept from the earlier sections is doing work: coroutineScope for structured boundaries, async for parallel values, Dispatchers.IO for the network calls, the parent child Job link for "one failure cancels siblings," and the CancellationException rethrow to keep cancellation honest inside the retry loop.
Where to go deeper
What this article covered is the working vocabulary of coroutines: enough to read and write real code without guessing. There are two directions to go next, depending on what you want.
If you want to understand the compiler machinery, what the suspend keyword actually does to your bytecode, how the Continuation interface is used, how COROUTINE_SUSPENDED threads through the state machine, that is the topic of /en/guides/suspend-function-internals. Read it when plain APIs stop being enough and you want to reason about coroutines the way you would reason about a C function.
If you want to understand streams, hot vs cold, Flow, StateFlow, SharedFlow, and where each belongs, start with /en/compare/flow-vs-livedata. That guide is an opinionated take on when Flow replaces observable UI primitives and when it does not.
And if you want the full book length treatment with everything this post omitted (Channel types and select, custom Flow operators, hot stream sharing, the entire StateFlow/SharedFlow internals, testing strategies for coroutine code, and a concurrency by example chapter on Android and KMP) that is what Practical Kotlin Deep Dive is for.
Practical Kotlin Deep Dive · Course
26 hands-on Code Playgrounds, 158 interactive assessments, certificate of completion. The same content as the book, designed for active practice.
Explore the course →As always, happy coding!
— Jaewoong (skydoves)
Frequently asked questions
What are Kotlin coroutines?â–¾
Kotlin coroutines are language-level suspendable computations. A coroutine is a small state-machine object that runs on top of a regular thread and can pause at a suspension point without blocking that thread, freeing it up for other work. When the awaited operation completes, the coroutine resumes where it left off, possibly on a different thread. That makes it practical to run tens of thousands of concurrent tasks with sequential-looking code.
What is the difference between a coroutine and a thread?â–¾
Threads are OS-level primitives with large stacks and preemptive scheduling, expensive to create and context-switch. Coroutines are user-space objects scheduled cooperatively by a Dispatcher; they run on threads but suspend without blocking them. You can realistically have millions of coroutines on a handful of threads, which is impossible with raw threads.
What does the suspend keyword actually do?â–¾
The suspend modifier tells the compiler to rewrite the function in Continuation-Passing Style. The suspend keyword disappears from the JVM signature, a Continuation parameter is appended, and the body becomes a state machine that can pause at each suspension point and resume via Continuation.resumeWith. Suspend functions can only be called from other suspend functions or from a coroutine builder.
What is structured concurrency in Kotlin?â–¾
Structured concurrency is the rule that every coroutine has a parent Job, that parents wait for their children before completing, and that cancelling a parent cancels all of its children. In practice it means coroutineScope { } and supervisorScope { } never leak work, nothing escapes the block unnoticed. That single rule eliminates most leaks and orphan tasks.
When should I use launch versus async?â–¾
Use launch when you want a side effect and a Job handle, fire-and-forget work like triggering a save, logging, or pushing a UI update. Use async when you want a computed value back; it returns Deferred<T>, and you resolve it with .await(). Exceptions inside async are stored and rethrown on await, so failures in parallel async tasks do not crash the scope until you actually consume the result.
What dispatcher should I use on Android?â–¾
Use Dispatchers.Main (or Main.immediate for UI state that is already on the main thread) for anything that touches the UI. Use Dispatchers.IO for network calls, disk, and database. Use Dispatchers.Default for CPU-bound work such as parsing, image manipulation, or encryption. Dispatchers.Unconfined is rarely the right answer outside library internals and tests.
How does coroutine cancellation work?â–¾
Cancellation is cooperative. Calling job.cancel() sets the Job into a cancelling state and every suspension point checks the flag. Suspending functions from kotlinx.coroutines throw CancellationException on suspension when the Job is cancelled. For tight CPU loops with no suspensions, you have to cooperate yourself via isActive, ensureActive(), or yield().
What is the difference between a Job and a SupervisorJob?â–¾
Both track a coroutine's lifecycle and can be cancelled. A regular Job propagates child failure upward, one crashed child cancels the parent, which cancels every sibling. A SupervisorJob overrides that behavior so a failing child does not cancel its parent or siblings. Use SupervisorJob in UI-facing scopes, like viewModelScope, where one failed request should not tear down the whole screen.
Is GlobalScope safe to use?â–¾
Almost never in application code. GlobalScope has an EmptyCoroutineContext, no Job, no lifecycle, no dispatcher, so coroutines launched in it live as long as the process and cannot be cancelled by any parent. That means memory leaks, wasted work after navigation, and flaky tests. Use a lifecycle-bound scope (viewModelScope, lifecycleScope) or a custom CoroutineScope tied to an owner object.
Related reading
Kotlin Suspend Function Internals: Continuations & State Machines
Trace a suspend function through compiler, stdlib Continuation, trampoline, and back, with real source code.
Kotlin Flow vs LiveData: Which to Pick in 2026
When StateFlow wins, when LiveData is still the right call, and how to migrate without breaking production.
Practical Kotlin Deep Dive
492 pages, 70 deep-dive topics, from coroutines to compiler internals. By Jaewoong Eum, Google Developer Expert.


