Diagnose Kotlin Coroutine Scope Leaks and Sibling Cancellation
Diagnose coroutine scope leaks, unexpected sibling cancellation, and stalled shutdown in Kotlin structured concurrency with scoped checks and fixes.
29 Jul 2026, 04:28 UTC

Recognizable condition
\nYou see one of these symptoms in a Kotlin coroutine-based application:
\n- \n
- A network request or database query keeps running after the screen or component that started it is destroyed. \n
- One failing child coroutine cancels every sibling in the same block, even though the tasks are independent. \n
- Calling cancel() on a scope does not stop work for seconds, or not at all. \n
- The UI thread freezes while a coroutine is running. \n
Cause and diagnostic table
\n| Symptom | Likely cause | First check |
|---|---|---|
| Coroutine survives component destruction | Launched in GlobalScope or an unbound shared scope | Is the scope tied to the component lifecycle? |
| One failing child cancels siblings | Regular Job or coroutineScope propagates failure | Are you using SupervisorJob or supervisorScope? |
| Cancellation is slow or never happens | Blocking loop without isActive or yield() | Does the suspend function check cancellation? |
| UI freezes during coroutine work | Blocking call on Dispatchers.Main | Is withContext(Dispatchers.IO) used? |
Ordered checks
\n- \n
- Confirm scope ownership. Find where the coroutine builder (launch, async) is called. Ask which CoroutineScope receives the job. If the answer is GlobalScope or a scope stored in a companion object, the coroutine is not tied to any component lifetime. \n
- Inspect the Job type. Determine whether the parent uses a regular Job, SupervisorJob, coroutineScope, or supervisorScope. A regular Job cancels siblings when a child fails; SupervisorJob does not. \n
- Reproduce with scope cancellation. Cancel the scope in a test and observe whether child jobs reach isCancelled=true. If they do not, the work is likely ignoring cancellation. \n
- Check the dispatcher. Confirm that blocking file, network, or database calls run under Dispatchers.IO or Dispatchers.Default, never directly on Dispatchers.Main. \n
Fixes matched to findings
\n\nFix 1: Bind the scope to the component lifecycle
\nReplace GlobalScope with a scope you own and cancel when the component is destroyed.
\nclass ReportProcessor {\n private val scope = CoroutineScope(Job() + Dispatchers.Default)\n\n fun process() = scope.launch {\n // coroutine work here\n }\n\n fun shutdown() {\n scope.coroutineContext.cancel()\n }\n}\nOn Android, lifecycleScope or viewModelScope already provide this binding and cancel automatically when the owner is cleared. Outside Android, create the scope as a property of the class that owns the work and cancel it in close(), dispose(), or onCleared().
\n\nFix 2: Isolate independent failures with SupervisorJob
\nUse SupervisorJob when one child failing should not cancel its siblings.
\nfun runIndependentJobs() = CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {\n supervisorScope {\n launch { criticalTaskA() }\n launch { criticalTaskB() } // continues if taskA throws\n }\n}\nWith a regular Job or coroutineScope{}, one throwing child cancels the parent and all siblings. SupervisorJob only isolates child failures; cancelling the scope itself still cancels every child.
\n\nFix 3: Make long loops cancellation-aware
\nCancellation is cooperative. A tight loop that never checks isActive or calls yield() ignores cancel() until it reaches a suspending function.
\nwhile (items.hasNext()) {\n process(items.next())\n if (!isActive) throw CancellationException()\n // or periodically:\n // yield()\n}\n\nFix 4: Move blocking work off Dispatchers.Main
\nNever perform file I/O or network calls directly on Dispatchers.Main. Switch with withContext.
\nwithContext(Dispatchers.IO) {\n val data = file.readBytes()\n}\nwithContext(Dispatchers.Main) {\n ui.show(data)\n}\n\nPractical verification
\nRun this check on the target Kotlin and kotlinx.coroutines versions before relying on the fix. It assumes kotlinx.coroutines 1.x and a test dependency on kotlinx-coroutines-test.
\nWhere to run: in the module's unit-test source set, as a normal user (no sudo required).
\nimport kotlinx.coroutines.*\nimport kotlinx.coroutines.test.*\n\n@Test\nfun scopeCancellationStopsChild() = runTest {\n val scope = CoroutineScope(Job() + Dispatchers.Unconfined)\n val job = scope.launch {\n delay(Long.MAX_VALUE)\n }\n scope.cancel()\n assertTrue(job.isCancelled)\n}\nExpected check: job.isCancelled is true and the launch block throws CancellationException. If the job is still active, the scope is not the one you think it is, or the block contains non-cancellable blocking code.
\nRisk: tests using real delay() are slow and flaky. Prefer runTest or VirtualTimeScope for timing-sensitive assertions.
\n\nEscalation criteria
\n- \n
- If scope.cancel() returns immediately but work continues, look for coroutines launched with GlobalScope or a scope you did not cancel. \n
- If children ignore cancellation, inspect third-party suspend functions for swallowed CancellationException or blocking loops without isActive checks. \n
- If the failure pattern only appears in production, enable coroutine debugging and capture thread dumps to see which dispatcher is blocked. \n
Limitations
\n- \n
- Structured concurrency manages lifetimes, not thread safety. Shared mutable state still needs synchronization. \n
- Cancellation is cooperative; blocking native calls or tight computation loops cannot be interrupted mid-call. \n
- SupervisorJob isolates child exceptions, but cancelling the parent SupervisorJob still cancels all children. \n
Rollback
\nThese changes are source-level only. Roll back by reverting the commit that replaced GlobalScope, changed Job to SupervisorJob, or added withContext calls, then rebuild and rerun the verification test.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.