Fix: Kotlin Coroutine Not Executing — launch{} or async{} Blocks Not Running
Part of: Java & JVM Errors
Quick Answer
How to fix Kotlin coroutines not executing — CoroutineScope setup, dispatcher selection, structured concurrency, cancellation handling, blocking vs suspending calls, and exception propagation.
The Problem
A Kotlin coroutine block never runs:
fun fetchData() {
CoroutineScope(Dispatchers.IO).launch {
val result = api.getData() // Never called
updateUI(result)
}
// Function returns immediately — coroutine may not have started
}Or a coroutine starts but gets cancelled before completing:
viewModelScope.launch {
val data = withTimeout(1000) {
api.getData() // TimeoutCancellationException — caught silently
}
_state.value = data // Never reached
}Or async results are never collected:
val deferred = CoroutineScope(Dispatchers.IO).async {
heavyComputation()
}
// deferred.await() never called — result lost, exceptions swallowedOr a suspend function called from non-coroutine context:
// Error: Suspension functions can be called only within a coroutine body
val result = suspendingFunction()Why This Happens
Kotlin coroutines require an active CoroutineScope and a CoroutineContext to run. The most frequent failure is a scope that gets cancelled or garbage-collected before the coroutine reaches its first suspension point. Creating a throwaway CoroutineScope inside a function means the scope has no owner — once the function returns, the scope is eligible for garbage collection, and the coroutine may be cancelled mid-flight without any log output.
Exception handling adds another layer of confusion. An uncaught exception in launch cancels the coroutine and propagates to the parent scope, but the propagation path depends on the Job hierarchy. With a regular Job, a single child failure cancels all siblings and the parent. With SupervisorJob, siblings survive. In either case, if no CoroutineExceptionHandler is installed, the exception may reach the thread’s uncaught exception handler — which on Android crashes the app, but on a server may just log to stderr where nobody reads it.
Dispatcher misuse is the third pillar of coroutine failures. CPU-bound work on Dispatchers.Main freezes the UI thread and triggers Android ANR dialogs after five seconds. UI updates dispatched to Dispatchers.IO throw CalledFromWrongThreadException. Blocking calls like Thread.sleep() or synchronous HTTP inside a coroutine block the dispatcher thread, starving other coroutines waiting for the same thread pool. And runBlocking in production code blocks the calling thread entirely, defeating cooperative concurrency.
Other common failure modes:
asyncwithoutawait—asyncstarts a coroutine but the result (and any exceptions) are deferred. Without.await(), exceptions are silently dropped.- Catching
CancellationException— a blanketcatch (e: Exception)swallows cancellation, preventing the coroutine from terminating properly. - Flow not collected —
Flowis cold. Defining one without calling.collect {}does nothing.
In Production: Incident Lens
Non-executing coroutines surface differently depending on the platform. On Android, the most visible symptom is an ANR (Application Not Responding) dialog when runBlocking ties up the main thread, or a silent feature failure where a network call simply never completes and the UI shows a permanent loading spinner. Users report “the app is stuck” or “nothing happens when I tap the button.” Crash analytics tools like Firebase Crashlytics may not capture the issue at all because there is no exception — the coroutine just never ran.
On server-side Kotlin (Ktor, Spring), the failure is even quieter. A cancelled scope drops the coroutine without logging. An endpoint returns a 200 with an empty or default response body because the async block that was supposed to fetch data never delivered its result. Monitoring dashboards show normal latency and throughput, but downstream data is missing. Users report “my order went through but the confirmation email never arrived” or “the webhook payload is empty.”
Blast radius: Scope-level cancellation is the worst case. If a CoroutineScope backed by a regular Job has one child fail, every sibling coroutine in that scope is cancelled. A single network timeout can cascade into dozens of cancelled background tasks — cache refreshes, metric flushes, notification sends — all killed silently.
Monitoring signals to watch:
- Android: ANR rate in Play Console,
StrictModeviolations for network on main thread, crash-free user percentage drops without corresponding crash spikes (indicates silent failures) - Server: response body size anomalies (smaller than expected), downstream service call counts dropping (coroutine never made the call), thread pool exhaustion metrics on
Dispatchers.IO
Recovery sequence: Identify the affected scope. If a SupervisorJob scope is healthy but individual children failed, restarting the specific feature is enough. If a regular Job scope was cancelled, the entire scope must be recreated. On Android, this often means the user must navigate away and back (ViewModel recreation). On servers, restart the service or the specific scope holder.
Postmortem preventives: Install a CoroutineExceptionHandler on every production scope. Use SupervisorJob for scopes that host independent work. Add structured logging at coroutine launch and completion (launch { log("start"); try { ... } finally { log("end") } }). Set up alerting on coroutine debug probe dumps in staging environments.
Fix 1: Use the Right CoroutineScope
Each Android/Kotlin context has a built-in scope — prefer these over creating your own:
// Android ViewModel — cancelled when ViewModel is cleared
class UserViewModel : ViewModel() {
fun loadUser(id: String) {
viewModelScope.launch { // Tied to ViewModel lifecycle
val user = userRepository.getUser(id)
_user.value = user
}
}
}
// Android Fragment/Activity — cancelled on destroy
class UserFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch { // Tied to view lifecycle
val user = viewModel.loadUser()
binding.nameText.text = user.name
}
}
}
// Kotlin application / backend — use a supervisor scope
class DataProcessor {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun process(data: List<String>) {
scope.launch {
data.forEach { item ->
launch { processItem(item) } // Child coroutines
}
}
}
fun shutdown() {
scope.cancel() // Cancel all children when done
}
}WRONG — scope goes out of scope before coroutine finishes:
fun fetchData() {
// This scope is not stored — garbage collected when fetchData returns
CoroutineScope(Dispatchers.IO).launch {
delay(100)
println("Done") // May never run — scope cancelled by GC
}
}
// CORRECT — use a stable scope
class DataService {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun fetchData() {
serviceScope.launch {
delay(100)
println("Done") // Runs reliably
}
}
}Fix 2: Choose the Right Dispatcher
Each dispatcher optimizes for a different kind of work:
// Dispatchers.Main — UI thread (Android). Use for: updating views, observing LiveData
viewModelScope.launch(Dispatchers.Main) {
binding.progressBar.isVisible = true
}
// Dispatchers.IO — thread pool for blocking I/O. Use for: network, disk, database
viewModelScope.launch(Dispatchers.IO) {
val data = database.queryAll() // Blocking DB call — fine on IO
val response = api.fetchUser(id) // Network call — fine on IO
}
// Dispatchers.Default — thread pool for CPU work. Use for: parsing, sorting, computation
viewModelScope.launch(Dispatchers.Default) {
val processed = data.sortedBy { it.timestamp } // CPU-bound sort
val parsed = Json.decodeFromString<List<Item>>(jsonString)
}
// Dispatchers.Unconfined — runs in caller's thread until first suspension
// Avoid in production — unpredictable behavior
// Switch dispatcher mid-coroutine using withContext
viewModelScope.launch {
val data = withContext(Dispatchers.IO) {
repository.fetchData() // IO work
}
// Back on Main (or the launch dispatcher) here
_state.value = data
}Pro Tip: In Android, viewModelScope and lifecycleScope default to Dispatchers.Main. Always switch to Dispatchers.IO for any blocking work inside them.
Fix 3: Handle Cancellation Correctly
Coroutines are cooperative — they check for cancellation at suspension points:
// Coroutine cancellation check — suspension points check automatically
viewModelScope.launch {
for (item in largeList) {
ensureActive() // Throw CancellationException if cancelled
processItem(item) // If this is blocking (non-suspend), cancellation won't interrupt it
}
}
// NON-cancellable blocking work — explicitly check
viewModelScope.launch {
for (item in largeList) {
if (!isActive) break // Manual check for cancellation
heavyBlockingWork(item) // Non-suspend — coroutine won't auto-cancel here
}
}
// withContext(NonCancellable) — complete a critical section even if cancelled
viewModelScope.launch {
try {
val result = repository.save(data)
_state.value = result
} finally {
withContext(NonCancellable) {
// This runs even if the coroutine was cancelled
database.cleanup()
}
}
}Don’t catch CancellationException and swallow it:
// WRONG — swallows cancellation, coroutine thinks it finished normally
launch {
try {
suspendingWork()
} catch (e: Exception) {
log(e) // Catches CancellationException — coroutine now "stuck"
}
}
// CORRECT — re-throw CancellationException
launch {
try {
suspendingWork()
} catch (e: CancellationException) {
throw e // Always re-throw — let the coroutine cancel properly
} catch (e: Exception) {
log(e) // Handle other exceptions
}
}
// Or use a specific exception type
launch {
try {
suspendingWork()
} catch (e: IOException) {
handleNetworkError(e) // Only catch what you can handle
}
// CancellationException propagates naturally
}Fix 4: Use async/await for Parallel Work
async returns a Deferred — always call .await() to get the result and surface exceptions:
// WRONG — exceptions from async are swallowed, result ignored
fun loadData() {
viewModelScope.launch {
val deferred = async { api.getData() }
// deferred.await() never called — exception lost
_state.value = someOtherData
}
}
// CORRECT — await the deferred
fun loadData() {
viewModelScope.launch {
val deferred = async { api.getData() }
try {
val result = deferred.await() // Exception thrown here if async failed
_state.value = result
} catch (e: Exception) {
_error.value = e.message
}
}
}
// Parallel requests — both run simultaneously
fun loadUserAndPosts(userId: String) {
viewModelScope.launch {
val userDeferred = async { userRepository.getUser(userId) }
val postsDeferred = async { postRepository.getPosts(userId) }
// Both run in parallel — wait for both to complete
val user = userDeferred.await()
val posts = postsDeferred.await()
_state.value = UserWithPosts(user, posts)
}
}
// awaitAll — cleaner syntax for multiple parallel calls
fun loadAll(ids: List<String>) {
viewModelScope.launch {
val results = ids.map { id ->
async { repository.getItem(id) }
}.awaitAll() // Waits for all, throws if any fails
_items.value = results
}
}Fix 5: Replace Callbacks with Suspending Functions
Wrap callback-based APIs using suspendCoroutine or callbackFlow:
// Wrap a callback-based API as a suspend function
suspend fun fetchLocation(): Location = suspendCoroutine { continuation ->
locationClient.getCurrentLocation(
priority = Priority.PRIORITY_HIGH_ACCURACY,
cancellationToken = null,
).addOnSuccessListener { location ->
continuation.resume(location) // Resume with result
}.addOnFailureListener { exception ->
continuation.resumeWithException(exception) // Resume with exception
}
}
// Usage — now callable from coroutines
viewModelScope.launch {
try {
val location = fetchLocation() // Suspends until callback fires
_location.value = location
} catch (e: Exception) {
_error.value = "Location unavailable"
}
}Convert a continuous callback stream to a Flow:
// callbackFlow — for ongoing streams of events
fun locationUpdates(): Flow<Location> = callbackFlow {
val callback = LocationCallback { result ->
result.locations.forEach { location ->
trySend(location) // Send to flow — non-blocking
}
}
fusedLocationClient.requestLocationUpdates(
locationRequest,
callback,
Looper.getMainLooper(),
)
awaitClose {
// Called when the flow collector cancels
fusedLocationClient.removeLocationUpdates(callback)
}
}
// Collect in a coroutine
viewModelScope.launch {
locationUpdates()
.distinctUntilChanged()
.collect { location ->
_location.value = location
}
}Fix 6: Handle Exceptions with SupervisorJob
By default, a child coroutine exception cancels the parent scope (and all siblings). Use SupervisorJob to isolate failures:
// Default behavior — one failure cancels everything
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
// If this throws, scope is cancelled
riskyOperation()
}
scope.launch {
// This is also cancelled because the scope failed
normalOperation()
}
// SupervisorJob — children fail independently
val supervisorScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
supervisorScope.launch {
// If this throws — only this coroutine fails
riskyOperation()
}
supervisorScope.launch {
// This continues running even if riskyOperation failed
normalOperation()
}
// supervisorScope builder — for one-off supervised scopes
viewModelScope.launch {
supervisorScope {
val a = launch { loadPartA() } // Failure doesn't cancel B
val b = launch { loadPartB() } // Failure doesn't cancel A
}
}CoroutineExceptionHandler — catch unhandled exceptions:
val handler = CoroutineExceptionHandler { _, exception ->
Log.e("Coroutine", "Unhandled exception", exception)
// exception is a non-CancellationException that reached the root
}
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + handler)
scope.launch {
throw RuntimeException("Something failed")
// Caught by handler — scope not cancelled (because of SupervisorJob)
}Fix 7: Debug Coroutine Issues
Enable coroutine debug mode in development:
// In Application.onCreate() or test setup
System.setProperty("kotlinx.coroutines.debug", "on")
// Each thread shows its current coroutine:
// Thread[DefaultDispatcher-worker-1 @coroutine#1,5,main]
// Dump all active coroutines (useful for finding stuck coroutines)
// Add to build.gradle:
// implementation "org.jetbrains.kotlinx:kotlinx-coroutines-debug:1.x.x"
import kotlinx.coroutines.debug.DebugProbes
DebugProbes.install() // Call at app start
// In a debug menu or crash handler:
DebugProbes.dumpCoroutines() // Prints all active coroutines and their stack tracesFind deadlocks — runBlocking on main thread:
// DEADLOCK — runBlocking on main thread, coroutine tries to switch to main
fun badExample() {
runBlocking { // Blocks main thread
withContext(Dispatchers.Main) { // Tries to run on main — deadlock!
updateUI()
}
}
}
// CORRECT — never use runBlocking on Android main thread
// Use a coroutine scope instead
lifecycleScope.launch {
updateUI()
}Still Not Working?
Coroutine not starting at all — if launch or async doesn’t execute, check that the scope is still active. A cancelled scope silently ignores new coroutines. Add println(scope.isActive) before launch.
GlobalScope leaks — GlobalScope.launch creates coroutines that live for the entire application lifetime. They’re never cancelled by component lifecycle. Avoid GlobalScope in application code; use structured concurrency scopes tied to a lifecycle.
Flow not collecting — Flow is cold — it only executes when collected. Defining a Flow without calling .collect {} (or .launchIn(scope)) does nothing. Ensure the collector is active.
Unit tests with coroutines — use kotlinx-coroutines-test and runTest for testing suspend functions:
@Test
fun testSuspendFunction() = runTest {
val result = mySuspendFunction()
assertEquals(expected, result)
// runTest auto-advances virtual time for delays
}Dispatchers.IO thread pool exhaustion — the default Dispatchers.IO pool is limited to 64 threads. If all threads are blocked by long-running synchronous calls, new coroutines on Dispatchers.IO queue indefinitely. Use Dispatchers.IO.limitedParallelism(n) to create a separate sub-dispatcher for heavy blocking work, or increase the pool with kotlinx.coroutines.io.parallelism.
withTimeout silently cancels — withTimeout throws TimeoutCancellationException, which is a subclass of CancellationException. A blanket catch (e: Exception) swallows it, and the coroutine appears to finish normally with no result. Use withTimeoutOrNull if you want a null fallback instead of an exception, and always log timeout occurrences in production.
Ktor client sharing a scope with the server — if you create an HttpClient tied to a scope that gets cancelled (e.g., a request scope), in-flight HTTP calls are aborted. Create the HttpClient with its own long-lived scope, or use the application-level scope.
For related issues, see Fix: Kotlin Coroutine Scope Cancelled, Fix: Kotlin Flow Not Working, Fix: Java ConcurrentModificationException, and Fix: C# Async Deadlock.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Kotlin Flow Not Working — Not Collecting, StateFlow Not Updating, or Flow Cancelled Unexpectedly
How to fix Kotlin Flow issues — cold vs hot flows, collectLatest vs collect, StateFlow and SharedFlow setup, lifecycle-aware collection in Android, and common Flow cancellation problems.
Fix: Kotlin Coroutine Scope Cancelled — JobCancellationException or Coroutine Not Running
How to fix Kotlin coroutine cancellation issues — scope lifecycle, SupervisorJob, CancellationException handling, structured concurrency, viewModelScope, and cooperative cancellation.
Fix: Kotlin Sealed Class Not Working — when Expression Not Exhaustive or Subclass Not Found
How to fix Kotlin sealed class issues — when exhaustiveness, sealed interface vs class, subclass visibility, Result pattern, and sealed classes across modules.
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.