Kotlin coroutines are everywhere now. They promise lightweight concurrency, structured cancellation, and a syntax that reads like sequential code. But in practice, many codebases end up with a tangled mess of GlobalScope.launch, uncaught exceptions, and thread pool contention. This guide is for professionals who already know the basics and want to curate their coroutine usage with intent—choosing the right tools for the right problems, and knowing when to say no.
Why Intentional Coroutine Design Matters Now
As Kotlin coroutines mature, the community is moving beyond the initial excitement and confronting real-world maintenance costs. Early adopters often treated coroutines as a drop-in replacement for callbacks or RxJava, but that approach ignored the discipline of structured concurrency. The result? Leaked coroutines that outlive their parent scope, unexpected thread switches, and debugging sessions that feel like chasing ghosts.
Teams that succeed with coroutines treat them as a design choice, not a default. They ask: does this operation need to be concurrent, or just asynchronous? Should it run on the main thread or a background pool? What happens if the user navigates away? These questions are not new—they apply to any concurrency model—but coroutines make it easy to ignore them because the syntax hides complexity.
Consider a typical Android ViewModel. Without intention, you might write viewModelScope.launch { fetchData() } and call it done. That works until fetchData launches its own coroutines that outlive the ViewModel, or until you need to cancel a specific request without killing everything. Intentional design means choosing the right scope, dispatcher, and cancellation strategy for each task, and documenting those choices in code reviews.
For backend services, the stakes are higher. A misused coroutine can hold a database connection longer than necessary, or silently swallow an exception that should have crashed the request. The art is in curating—not just using—coroutines.
Core Idea: Structured Concurrency as a Discipline
Structured concurrency is the principle that every coroutine should have a well-defined parent scope, and that scope's lifecycle determines the coroutine's lifecycle. When the parent completes or is cancelled, all its children are cancelled automatically. This mirrors the natural hierarchy of UI components, request handlers, or job queues.
The key insight is that launch and async are not fire-and-forget. They create a child coroutine that inherits the parent's context, including its Job and CoroutineDispatcher. If you use GlobalScope, you break that hierarchy. The coroutine becomes a root that lives until the application dies, or until you manually cancel it. This is almost never what you want.
In practice, structured concurrency means:
- Using
viewModelScope,lifecycleScope, or a customCoroutineScopetied to a component's lifecycle. - Avoiding
GlobalScopein production code—it's a sign that you haven't thought about lifecycle. - Propagating cancellation: if a parent is cancelled, children should stop. This is automatic if you use structured concurrency.
- Handling exceptions in the right place:
CoroutineExceptionHandlerortry/catcharound the parent launch, not scattered inside children.
One team I read about had a recurring bug where a network request continued after the user closed the app. The root cause was a GlobalScope.launch inside a repository that was called from multiple ViewModels. The fix was to pass the scope from the ViewModel, making the repository scope-aware. That's structured concurrency in action.
What Structured Concurrency Is Not
It's not about nesting coroutines for the sake of hierarchy. Sometimes a flat structure with explicit cancellation is clearer. The discipline is in choosing when to nest and when to keep things independent. For example, launching multiple independent network requests in parallel can be done with async inside a parent coroutine, but if each request has its own lifecycle, a separate scope might be better.
How It Works Under the Hood: Dispatchers, Context, and Cancellation
Coroutines are not threads, but they run on threads via dispatchers. The dispatcher determines which thread pool the coroutine uses. Dispatchers.Main runs on the UI thread (on Android), Dispatchers.IO is optimized for blocking I/O, and Dispatchers.Default is for CPU-bound work. Misusing these is a common source of performance issues.
When you call withContext(Dispatchers.IO), the coroutine suspends and resumes on the IO pool. This is efficient because it avoids blocking the main thread, but it also means you're switching threads. Each switch has overhead, so chaining many withContext calls can hurt performance. The rule of thumb: use the default dispatcher for computational work, IO for blocking calls, and main for UI updates.
Cancellation is cooperative. A coroutine must check for cancellation at suspension points. If you have a tight loop without suspension, cancellation won't happen until the loop ends. You can call yield() or ensureActive() to make it cancellable. This is important for long-running operations like processing a list.
Exception handling also follows the hierarchy. An uncaught exception in a child coroutine cancels the parent (unless you use SupervisorJob). This is by design: if a child fails, the parent should know and react. But if you want independent failure handling, use supervisorScope or SupervisorJob. This pattern is common in UI code where one failed task shouldn't crash the whole screen.
Context Propagation
Coroutine context includes the job, dispatcher, and any custom elements like a logger or authentication token. When you launch a coroutine inside another, it inherits the parent's context. You can override elements with + operator. This is how you pass a custom dispatcher or a CoroutineName for debugging. Context propagation is powerful but can lead to surprises if you assume a specific dispatcher is always present.
Worked Example: Search-as-You-Type with Debounce and Cancellation
Let's build a search feature that sends a request after the user stops typing for 300ms. The naive approach launches a coroutine on every keystroke, but that floods the network and may return stale results. The intentional approach uses a single coroutine with delay for debounce and cancellation of previous requests.
In a ViewModel:
private var searchJob: Job? = null
fun onQueryChanged(query: String) {
searchJob?.cancel()
searchJob = viewModelScope.launch {
delay(300)
val results = repository.search(query)
_results.value = results
}
}Each time the query changes, we cancel the previous job and start a new one. The delay gives the user time to type. If the user types quickly, only the last keystroke triggers a request. This works because delay is a suspension point that checks cancellation.
But there's a subtlety: if repository.search is a suspend function that uses withContext(Dispatchers.IO), cancellation will be checked at the start of that block. If the network call is already in flight, cancellation may not stop it immediately—the response will be discarded when the coroutine resumes and checks cancellation. That's fine for most cases, but if you need to abort the HTTP request itself, you need a cancellable client like OkHttp with a Call object that you can cancel manually.
A more reliable alternative uses flow with debounce, flatMapLatest, and catch. Flows are built on coroutines and provide operators for debouncing, switching, and error handling. The equivalent flow-based approach is cleaner:
private val _query = MutableStateFlow("")
init {
viewModelScope.launch {
_query
.debounce(300)
.flatMapLatest { query ->
repository.search(query).catch { emit(emptyList()) }
}
.collect { results ->
_results.value = results
}
}
}
fun onQueryChanged(query: String) {
_query.value = query
}This handles cancellation automatically via flatMapLatest, which cancels the previous search flow when a new query arrives. It's more declarative and less error-prone.
When to Choose Flow Over Manual Coroutines
If your data source is reactive (e.g., Room, network calls with callbacks), flow is usually the better choice. If you need imperative control over cancellation or complex error recovery, manual coroutines may be clearer. The key is to decide based on the data's nature, not familiarity.
Edge Cases and Exceptions
One common edge case is using async without await. If you launch an async coroutine and never call await, the deferred value is never consumed, but the coroutine still runs. This can lead to wasted resources. The compiler may warn you, but it's easy to ignore. Always ensure that every async is paired with an await or is cancelled explicitly.
Another edge case is exception propagation in supervisorScope. Inside a supervisorScope, a child's failure does not cancel siblings or the parent. But if the child uses async, the exception is deferred and only thrown when you call await. This means you must handle exceptions at the await point, not at the launch level. It's a common gotcha for teams migrating from RxJava, where errors are propagated through a separate channel.
Timeouts are another area where intention matters. Using withTimeout or withTimeoutOrNull is straightforward, but they throw TimeoutCancellationException if the block doesn't complete in time. This exception is a subclass of CancellationException, so it cancels the parent coroutine unless you catch it. If you want a timeout that doesn't cancel the parent, use withTimeoutOrNull and handle the null case.
Finally, consider thread starvation. If you launch many coroutines on Dispatchers.IO, they share a limited thread pool. Blocking operations (like Thread.sleep or a synchronous JDBC call) can exhaust the pool, causing other coroutines to wait. The fix is to use non-blocking alternatives or to increase the pool size, but that's a temporary solution. Better to audit your code for unintended blocking.
Testing Coroutines
Testing coroutines requires controlling the dispatcher. Use TestCoroutineDispatcher (or StandardTestDispatcher in newer versions) to advance time manually. Avoid relying on real delays in tests. Also, test cancellation by cancelling the scope and verifying that the coroutine stops. This is often overlooked but critical for correctness.
Limits of the Approach
Structured concurrency is not a silver bullet. It works best when the lifecycle of the parent is well-defined, like a ViewModel or a request handler. For long-lived background tasks (e.g., syncing data periodically), a structured scope tied to a UI component is inappropriate. You might need a separate scope managed by a service or WorkManager.
Another limit is debugging. Coroutine stacks can be hard to read because they include continuation frames. Tools like the Kotlin coroutine debugger and CoroutineName help, but it's still more complex than debugging synchronous code. Teams should invest in logging and monitoring from the start.
Performance is also a consideration. Coroutines are lightweight compared to threads, but they are not free. Each coroutine allocates a continuation object. Creating thousands of coroutines per second can cause GC pressure. For high-throughput systems, consider using a fixed thread pool with newSingleThreadContext or newFixedThreadPoolContext to limit concurrency.
Finally, coroutines are not a replacement for all async patterns. For CPU-bound parallel work (e.g., image processing), a thread pool may be more efficient. For event streams with complex backpressure, reactive streams might be a better fit. The art is knowing when coroutines add clarity and when they add complexity.
When Not to Use Coroutines
- For simple callbacks that don't need cancellation or lifecycle awareness.
- For operations that are already handled by a library (e.g., Retrofit's
enqueue). - For tasks that require precise control over thread scheduling (e.g., real-time audio).
Reader FAQ
Should I replace all RxJava with coroutines?
Not necessarily. Coroutines and flows can replace many RxJava use cases, but RxJava has richer operators for backpressure and complex event processing. If your codebase already uses RxJava well, migrating just for the sake of it may not be worth the effort. For new projects, coroutines are a good default.
How do I handle errors in a coroutine chain?
Use try/catch around the parent launch or async/await. For flows, use the catch operator. Avoid using CoroutineExceptionHandler for all errors—it's meant for root coroutines that don't have a parent. In structured concurrency, exceptions propagate upward naturally.
What's the best way to cancel a coroutine from outside?
Cancellation is cooperative, so you need a reference to the Job. Store the job returned by launch and call cancel() on it. For multiple coroutines, cancel the parent scope. This is why tying scopes to lifecycles is important.
Can I use coroutines with Spring WebFlux?
Yes, Spring supports Kotlin coroutines through suspend functions in controllers and Flow for reactive returns. The integration is mature and works well with WebFlux's event loop.
How do I debug a coroutine that's not cancelling?
Check that the coroutine has suspension points (like delay, withContext, or yield) and that it's not stuck in a tight loop. Use ensureActive() inside loops. Also verify that the scope you're cancelling is the correct parent.
Next steps: audit your current codebase for GlobalScope usage and replace with scoped launches. Add CoroutineName to every coroutine for debugging. Write a test for cancellation behavior. And when in doubt, prefer flows for reactive data streams.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!