Skip to content

Fix: Kotlin Coroutine Not Executing — launch{} or async{} Blocks Not Running

FixDevs ·

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 swallowed

Or 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 CoroutineScope locally 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 launch cancel the coroutine and propagate to the parent scope. In some configurations, they’re swallowed without logging.
  • Calling blocking code from coroutinesThread.sleep(), blocking I/O, or synchronous network calls inside a coroutine block the coroutine’s thread, causing delays or deadlocks.
  • Wrong dispatcher — CPU-bound work on Dispatchers.Main freezes the UI; UI updates on Dispatchers.IO throw exceptions.
  • runBlocking in production coderunBlocking blocks the calling thread entirely, defeating the purpose of coroutines and causing ANRs on Android.
  • async without awaitasync starts 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 traces

Find 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 leaksGlobalScope.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 collectingFlow 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles