In the early days of Kotlin Coroutines at Big Tech Co, we treated them like “lightweight threads” and called it a day. But as our systems scaled and our codebase grew to millions of lines, we learned that “lightweight” doesn’t mean “ignore safety.” We’ve seen production outages caused by a single runBlocking in a hot path and memory leaks that stayed hidden for months because of GlobalScope.
As an Engineer, my job often involves cleaning up these patterns before they hit production. Here are the 10 most common mistakes I see in PRs today, and how we fix them.
GlobalScope is the easiest way to launch a coroutine, and that’s exactly why it’s dangerous. It’s a “fire and forget” mechanism that doesn’t bind to any lifecycle.
The Mistake:
// In a Repository or ViewModel
fun trackAnalytics(event: String) {
GlobalScope.launch {
service.postEvent(event) // If this hangs, it leaks forever
}
}The Reality:
GlobalScope is a top-level coroutine scope. It avoids structured concurrency. If postEvent takes 30 seconds due to a network timeout, and the user has already navigated away, that coroutine stays alive. In a busy app, this accumulates into a massive memory leak.
The Fix:
Bind to a lifecycle-aware scope or create a dedicated `ApplicationScope`.
class AnalyticsManager(private val scope: CoroutineScope) {
fun trackAnalytics(event: String) {
scope.launch {
service.postEvent(event)
}
}
}Dispatchers.Default is backed by a thread pool equal to the number of CPU cores. If you perform blocking IO here, you are literally stealing the CPU’s capacity to do work.
The Mistake:
suspend fun processData() = withContext(Dispatchers.Default) {
val data = someBlockingFileRead() // Blocks the thread!
transform(data)
}The Reality:
On an 8-core machine, 8 calls to processData will completely saturate Dispatchers.Default. Any other coroutine trying to do computation (like UI animations or sorting) will be queued.
The Fix:
Always use Dispatchers.IO for blocking operations. It is designed to scale its thread pool to handle blocking.
suspend fun processData() {
val data = withContext(Dispatchers.IO) {
someBlockingFileRead()
}
withContext(Dispatchers.Default) {
transform(data)
}
}Cancellation in Kotlin is cooperative. If you write a long-running loop that doesn’t check for cancellation, job.cancel() does absolutely nothing.
The Mistake:
val job = launch {
while (true) {
doHeavyComputation() // Never checks isActive
}
}
// Later...
job.cancel() // Job state is “Cancelling”, but the loop keeps runningThe Reality:
We’ve seen background tasks drain batteries because they refused to stop when the user backgrounded the app.
The Fix:
Use yield(), ensureActive(), or check the isActive property.
val job = launch {
while (isActive) {
doHeavyComputation()
yield() // Suspension point allows cancellation check
}
}This is a classic. launch propagates exceptions immediately to the parent. async encapsulates them in the Deferred result.
The Mistake:
val deferred = scope.async {
throw RuntimeException(”Boom!”)
}
// If you never call deferred.await(), the app might not crash immediately,
// but the parent scope is still cancelled!The Reality:
If async is used inside a coroutineScope block, the exception will bubble up and cancel everything else, even if you never call await(). Developers often expect async to be “safer” than launch, but it’s just more subtle.
The Fix:
Use supervisorScope if you want failure in one async block to not kill siblings, and always handle the result of await().
runBlocking bridges the regular world and the coroutine world. It blocks the current thread until the coroutine completes.
The Mistake:
// Inside an Android Main Thread or a Spring Boot Request Thread
fun getData(): Data = runBlocking {
api.fetchData()
}The Reality:
This is the #1 cause of deadlocks. If api.fetchData() tries to switch back to the Main thread while runBlocking is holding it, you have a classic deadlock. In backend services, it limits throughput to 1 request per thread.
The Fix:
Propagate suspend all the way up. If you *must* bridge (like in a main() function or a JUnit test), keep it at the very entry point.
We often see code that calls withContext(Dispatchers.Main) inside every single function “just in case.”
The Mistake:
suspend fun updateUI() {
withContext(Dispatchers.Main) { /* task 1 */ }
withContext(Dispatchers.IO) { /* small io */ }
withContext(Dispatchers.Main) { /* task 2 */ }
}The Reality:
Every withContext call is a potential context switch, which involves rescheduling the coroutine on a different thread pool. While coroutine context switches are faster than thread context switches, they aren’t free.
The Fix:
Batch operations by context or rely on the caller to provide the correct context if possible.
People think SupervisorJob makes a scope “invincible.”
The Mistake:
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
scope.launch {
launch { // This child is NOT supervised by default
throw Exception()
}
}The Reality:
A SupervisorJob only works for its *direct* children. In the example above, the inner launch is a child of the outer launch, which has a regular Job. When the inner one fails, it kills the outer one.
The Fix:
Use supervisorScope { ... } for localized supervision of child coroutines.
Singletons exist for the lifetime of the application. If you give a singleton a CoroutineScope and never cancel it, you’ve created a permanent home for leaked coroutines.
The Mistake:
object MyRepository : CoroutineScope by MainScope() {
fun doWork() {
launch { /* ... */ }
}
}The Reality:
Since MyRepository is an object, its scope is never cancelled. Any coroutine launched here that hangs will stay in memory until the process is killed.
The Fix:
Inject a scope that is managed by the application lifecycle or a component container (like Dagger/Hilt or Koin) that handles cleanup.
This is the fastest way to get a “Staff Engineer” comment on your PR.
The Mistake:
suspend fun waitAndRetry() {
Thread.sleep(1000) // ❌ DO NOT DO THIS
retry()
}The Reality:
Thread.sleep blocks the underlying thread. If you are on Dispatchers.Main, you freeze the UI for 1 second. If you are on Dispatchers.Default, you’ve just removed a CPU core from the pool.
The Fix:
Always use delay(1000). It suspends the coroutine, freeing up the thread for others, and schedules a wake-up call.
We often see CoroutineScope being passed into constructors like it’s a generic thread pool.
The Mistake:
class PaymentProcessor(private val scope: CoroutineScope) {
// ...
}The Reality:
A CoroutineScope carries a Job. If that Job is cancelled, the PaymentProcessor becomes useless—it will never be able to launch another coroutine. This leads to “ghost bugs” where features suddenly stop working without a crash.
The Fix:
Pass a specific Dispatcher or a factory, or ensure the scope being passed has the correct lifecycle expectations documented. Usually, you want the scope to belong to the caller’s lifecycle.
Kotlin Coroutines provide a layer of abstraction that makes concurrency look easy, but the underlying complexity hasn’t disappeared. As we scale, the “happy path” is easy; it’s the failure modes, cancellation, and resource management that define a “Staff” level implementation.
Master Structured Concurrency. Respect the Dispatcher. And for the love of all things holy, stop using GlobalScope.
*Happy coding.*

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.