Fix: Kotlin Coroutine Not Executing — launch{} or async{} Blocks Not Running
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. Common failure modes:
- Scope cancelled before coroutine runs — creating a
CoroutineScopelocally and immediately letting it go out of scope cancels all its children. The coroutine may start but is cancelled almost immediately. - Exception swallowed silently — uncaught exceptions in
launchcancel the coroutine and propagate to the parent scope. In some configurations, they’re swallowed without logging. - Calling blocking code from coroutines —
Thread.sleep(), blockingI/O, or synchronous network calls inside a coroutine block the coroutine’s thread, causing delays or deadlocks. - Wrong dispatcher — CPU-bound work on
Dispatchers.Mainfreezes the UI; UI updates onDispatchers.IOthrow exceptions. runBlockingin production code —runBlockingblocks the calling thread entirely, defeating the purpose of coroutines and causing ANRs on Android.asyncwithoutawait—asyncstarts a coroutine but the result (and any exceptions) are deferred. Without.await(), exceptions are silently dropped.
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
}For related issues, see Fix: Android ANR and Fix: Java Thread 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.