What this usually means
Coroutines in Kotlin are cooperative; they must check for cancellation explicitly or yield control. If your coroutine contains a blocking call like Thread.sleep(), a Java InputStream.read(), or a tight loop without yield(), cancellation will never happen. The coroutine is not interrupted—it just stops checking. Similarly, some third-party suspend functions may not be cancellation-aware, especially if they wrap callbacks without using suspendCancellableCoroutine. The fix is to ensure every blocking or long-running call either respects cancellation (e.g., use delay instead of Thread.sleep) or is wrapped with withContext(NonCancellable) only when truly necessary.
The first ten minutes — establish facts before touching code.
- 1Run the app and trigger cancellation, then take a thread dump: kill -3 <pid> or jstack <pid>. Look for threads named 'DefaultDispatcher-worker-*' stuck in a method like Thread.sleep or InputStream.read.
- 2Add a log statement at the start of the coroutine: Log.d("Coroutine", "isActive=${coroutineContext[Job]?.isActive}") and after each suspend point.
- 3Wrap the entire coroutine body in try-catch(Throwable) and log the exception: often CancellationException is swallowed.
- 4Check if you're using join() without a timeout: replace with job.cancelAndJoin() or withTimeout(5.seconds) { job.join() } to avoid indefinite hangs.
- 5Verify the CoroutineScope: if you cancel a scope but the coroutine was launched in a different scope (e.g., GlobalScope), it may not propagate.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchCoroutine body: search for Thread.sleep, InputStream.read(), Socket.read(), or other blocking JVM calls
- searchSuspend functions that use suspendCoroutine instead of suspendCancellableCoroutine (callback wrappers)
- searchLoops without yield() or ensureActive() calls: for(i in 0 until largeNumber) { ... } without any suspension point
- searchCoroutineExceptionHandler: missing handler can swallow CancellationException silently
- searchScope hierarchy: if using SupervisorJob, cancellation won't propagate to children
- searchUnit test code: runBlockingTest or runTest may behave differently; check the test dispatcher.
Practical causes, not theory. These are the things you will actually find.
- warningBlocking call inside coroutine: Thread.sleep, InputStream.read(), Future.get() without timeout
- warningUncooperative suspend function: custom suspend function using suspendCoroutine without calling continuation.invokeOnCancellation
- warningBusy loop: while (isActive) { /* CPU-bound work with no yield */ } — isActive is never checked if the loop is tight and never suspends
- warningUsing NonCancellable context incorrectly: wrapping code that should be cancellable with withContext(NonCancellable)
- warningCancelling the wrong Job: holding a reference to a parent Job but the child is launched with a different Job
- warningTest dispatcher not advancing: in unit tests, TestCoroutineDispatcher may not process cancellation unless you advance time
Concrete fix directions. Pick the one that matches your root cause.
- buildReplace Thread.sleep with delay(): delay is cancellable; Thread.sleep is not
- buildUse ensureActive() or yield() in tight loops: ensureActive() checks cancellation and throws CancellationException; yield() also allows other coroutines to run
- buildConvert suspendCoroutine to suspendCancellableCoroutine: add invokeOnCancellation to clean up resources and complete the continuation
- buildWrap blocking IO with withContext(Dispatchers.IO) { } but ensure the blocking call itself is cancellable — use interruptible channels or NIO
- buildUse kotlinx.coroutines.debug agent: add -Dkotlinx.coroutines.debug to get enhanced stack traces with coroutine creation sites
- buildIn tests, use runTest with advanceUntilIdle() or ensure that cancellation is processed by yielding control
A fix you cannot prove is a guess. Close the loop.
- verifiedAfter fix, call job.cancel() and then job.join() with a timeout: job.join(5.seconds) — it should complete within the timeout
- verifiedAdd log statements after each suspend point: they should not execute after cancellation
- verifiedUse isActive check: inside a loop, add if (!isActive) break; or ensureActive() and confirm the loop exits
- verifiedRun thread dump after cancellation: no thread should be stuck on the blocking call
- verifiedUnit test: launch a coroutine, cancel it, and assert that the coroutine completes within a short timeout (e.g., withTimeout(1.second) { job.join() })
- verifiedCheck CoroutineExceptionHandler: log any CancellationException to ensure it's being thrown and not swallowed
Things that make this bug worse or harder to find.
- warningCalling job.cancel() but not waiting for it: cancellation is asynchronous; use join() or cancelAndJoin()
- warningUsing runBlocking in production code: it blocks the thread and can mask cancellation issues
- warningIgnoring CancellationException: many developers catch Throwable and ignore, swallowing the cancellation signal
- warningAssuming all suspend functions are cancellable: always check third-party library documentation
- warningUsing GlobalScope: it creates top-level coroutines that are hard to cancel; prefer scoped coroutines
- warningOverusing NonCancellable: it should only wrap cleanup code, not the main logic
Android App Freezes on Logout — Coroutine Ignoring Cancel
Timeline
- 10:15User reports app freezes on logout; UI thread blocked for 30+ seconds
- 10:20Checked logs: no crash, but 'CancellationException' never logged
- 10:25Took thread dump: 'DefaultDispatcher-worker-2' stuck at InputStream.read() in ImageUploader
- 10:30Found the coroutine: viewModelScope.launch { uploadImage() }. uploadImage uses OkHttp execute() which blocks
- 10:35Confirmed: cancel() returns immediately but the upload continues until network timeout
- 10:40Replaced execute() with enqueue() callback wrapped in suspendCancellableCoroutine
- 10:45Added invokeOnCancellation to cancel the OkHttp call when coroutine is cancelled
- 10:50Tested logout: upload cancelled instantly, UI responsive
- 10:55Deployed fix; user confirmed no freeze
I got a high-priority ticket: the app freezes for 30+ seconds when the user logs out while an image is uploading. The UI thread wasn't blocked, but the logout action called viewModelScope.cancel() and then tried to navigate — the navigation never happened because the coroutine scope didn't cancel the upload coroutine.
I reproduced the issue and took a thread dump. I saw 'DefaultDispatcher-worker-2' in 'java.io.FileInputStream.readBytes' — a blocking I/O call. The coroutine was launched with viewModelScope.launch(Dispatchers.IO) and called OkHttp's execute() method, which blocks the thread until the response arrives. Even though we cancelled the scope, the coroutine never checks cancellation because it's deep in a blocking read.
The fix was to switch from execute() (blocking) to enqueue() (callback-based) and wrap it with suspendCancellableCoroutine. I added invokeOnCancellation to abort the OkHttp Call when the coroutine is cancelled. I also added ensureActive() in the callback to propagate cancellation. After the fix, logout cancels the upload immediately and the app navigates smoothly.
Root cause
Blocking OkHttp execute() call inside a coroutine: the coroutine never checks cancellation because it's blocked on InputStream.read().
The fix
Replaced execute() with enqueue() wrapped in suspendCancellableCoroutine, added invokeOnCancellation to cancel the OkHttp Call, and used ensureActive() in the callback.
The lesson
Any blocking call inside a coroutine will defeat cancellation. Always use cancellable suspend wrappers for I/O, and always check that third-party libraries are cancellation-aware.
Coroutine cancellation in Kotlin is cooperative, not preemptive. When you call job.cancel(), the coroutine's job is marked as cancelled, and a CancellationException is thrown at the next suspension point. The coroutine itself must be at a suspension point (like delay, yield, or any suspend function that checks cancellation) for the exception to be thrown. If the coroutine is executing CPU-bound code without any suspension points, it will never see the cancellation.
The key internal mechanism is the Job interface's isActive property. Every suspension point in the standard library calls ensureActive() which checks isActive and throws CancellationException if false. Custom suspend functions using suspendCoroutine must manually check cancellation by calling continuation.isActive or by using suspendCancellableCoroutine which provides invokeOnCancellation.
A common source of cancellation failures is third-party library functions that expose suspend functions but are not truly cancellable. For example, some Room database operations or Retrofit network calls may wrap blocking I/O without cancellation support. To check, inspect the source: if the function uses suspendCoroutine instead of suspendCancellableCoroutine, it likely doesn't handle cancellation. You can also test by wrapping the call in withTimeout(1.seconds) and see if it times out or hangs.
To fix, you can re-wrap the call: create a new suspend function that uses suspendCancellableCoroutine and calls the original blocking function on a background thread, while registering an invokeOnCancellation callback that interrupts the thread or closes the resource. This ensures cancellation is both detected and acted upon.
Unit testing coroutine cancellation is tricky because test dispatchers (like TestCoroutineDispatcher) may not trigger cancellation automatically. A common mistake is to cancel a coroutine and then immediately assert that it has stopped, without giving the dispatcher a chance to process the cancellation. Use advanceUntilIdle() or yield() to allow the coroutine to reach the next suspension point. Alternatively, use runTest with a timeout: assertThrows<TimeoutCancellationException> { withTimeout(100.ms) { job.join() } }.
Another pitfall is using runBlockingTest which blocks the test thread. Prefer runTest which uses a virtual time dispatcher. Also, be aware that SupervisorJob in test scopes can prevent cancellation from propagating; use Job() instead if you need strict cancellation propagation.
When a coroutine refuses to cancel, the fastest way to find the culprit is a thread dump. On Android, you can take a dump via adb: adb shell kill -3 <pid>. Look for threads named 'DefaultDispatcher-worker-*' or 'main' and examine their stack traces. If you see a thread stuck in Thread.sleep, InputStream.read, or a native method, that's likely the blocking point.
Kotlin coroutines provide a debug mode: add -Dkotlinx.coroutines.debug to your JVM arguments. This enriches stack traces with coroutine creation sites, making it easier to trace which coroutine is stuck. You can also use the 'kotlinx.coroutines.debug' agent for more detailed output. In Android, you can set it in your app's build.gradle: kotlin { freeCompilerArgs += ["-Xcoroutines=enable"] } or use the debug library.
Frequently asked questions
Why does job.cancel() not stop the coroutine immediately?
Coroutine cancellation is cooperative. cancel() sets a flag, but the coroutine only reacts when it reaches a suspension point. If the coroutine is in a blocking call or a tight loop without yield(), it won't stop until it hits a suspension point or the blocking call completes. You must ensure the code inside the coroutine is cancellable.
How do I cancel a coroutine that contains a blocking call like InputStream.read()?
You cannot cancel a blocking thread from the coroutine layer. The best approach is to use a cancellable alternative: for I/O, use NIO channels with Selector or use a library that supports interruption. Alternatively, run the blocking call on a dedicated thread and close the stream from invokeOnCancellation. For example, use suspendCancellableCoroutine and in the cancellation callback, call stream.close() which will cause read() to throw an IOException.
What's the difference between isActive, ensureActive(), and yield()?
isActive is a property that returns true if the coroutine's job is still active. ensureActive() checks isActive and throws CancellationException if false — use it in loops or at the start of long-running functions. yield() also checks cancellation but additionally suspends to give other coroutines a chance to run. Use yield() in CPU-intensive loops to avoid starving other coroutines; use ensureActive() when you just need to check cancellation without yielding.
Can I use withContext(NonCancellable) to prevent cancellation?
Yes, but only for cleanup code that must run even during cancellation, like closing resources or logging. Never use it to wrap regular logic because it will make the coroutine unresponsive to cancellation. Overusing NonCancellable is a common mistake that causes stuck coroutines.
Why does my coroutine cancel in debug mode but not in release?
This is often due to ProGuard/R8 optimizations removing debug logging or coroutine debug metadata. Ensure your ProGuard rules keep coroutine internals: -keep class kotlinx.coroutines.** { *; }. Also, debug mode may add extra suspension points (like logging) that help cancellation propagate. In release, tight loops without suspension points may not check cancellation.