Diagnosing and Fixing Kotlin Coroutine Structured Concurrency Violations
Detect and fix Kotlin coroutine leaks caused by broken structured concurrency, swallowed cancellations, and blocking calls. Follow the diagnostic table, ordered checks, and code fixes to keep coroutines under correct control.
22 Mar 2026, 22:57 UTC

Problem and Takeaway
When a coroutine keeps running after its parent scope is cancelled, or a CancellationException is swallowed and resources leak, the root cause is almost always a *structured concurrency* violation. Structured concurrency means every coroutine’s lifetime is bounded by a parent Job. If that parent is cancelled, all of its children must be cancelled too. The practical takeaway: keep every coroutine inside a clear CoroutineScope with a defined parent Job, and never swallow CancellationException unless you explicitly re‑throw it.
This guide targets Kotlin 1.9+ and kotlinx.coroutines 1.7+ running on the JVM. It focuses on server‑side code, but the same principles apply to Android lifecycle scopes.
Recognizable Conditions
| Condition | Likely Cause | First Diagnostic |
|---|---|---|
| Coroutine continues after parent cancellation | Launched with GlobalScope or a detached Job | Search for GlobalScope.launch and inspect Job.parent chain. |
| CancellationException swallowed, no cleanup | Catch block hides CancellationException | Search for catch (e: CancellationException) or generic catch (e: Exception) that doesn’t re‑throw. |
| Only first child failure observed | Default Job cancels siblings on first failure | Check if SupervisorJob or supervisorScope is used. |
| Resource leak after cancellation | Cleanup omitted or resource acquired outside structured scope | Trace acquisition and release of DB connections, files, etc. |
Hang on join or await | Blocking call on Dispatchers.Default or runBlocking in production | Look for Thread.sleep, synchronous JDBC, or runBlocking inside coroutines. |
Ordered Diagnostic Checks
- Verify the scope hierarchy. Pause the coroutine in the debugger and inspect its
Job. A proper child should have a non‑nullparentthat traces back to the request‑level scope. Anullparent or a reference toGlobalScopeindicates a violation. - Find swallowed cancellations. Grep the codebase for
catch (e: CancellationException)and for genericcatch (e: Exception)blocks. Ensure that anyCancellationExceptionis re‑thrown or allowed to propagate. If afinallyblock is missing, add one to guarantee cleanup. - Check failure isolation. Inspect whether the surrounding builder is
coroutineScope,supervisorScope, or a customSupervisorJob. If children must fail independently, use a supervisor; otherwise, the default semantics are correct. - Audit resource management. For each resource acquired inside a coroutine (database connections, file handles, HTTP clients), confirm that the acquisition occurs inside the coroutine’s scope and that the release is performed in a
finallyblock or via theuseextension. - Identify blocking operations. Search for
Thread.sleep, blocking JDBC calls, orrunBlockinginside production coroutines. Such calls should be wrapped withwithContext(Dispatchers.IO) { … }or moved to a dedicatedDispatchers.IOpool.
Fixes Tied to Findings
Replace Unstructured Launches
// Problem: coroutine lives for the whole process
GlobalScope.launch {
val conn = dataSource.getConnection()
conn.use { connection ->
// work
}
}
// Preferred: coroutine is a child of the request scope
coroutineScope.launch {
val conn = dataSource.getConnection()
conn.use { connection ->
// work
}
}
Rethrow CancellationException
try {
doWork()
} catch (e: CancellationException) {
throw e // let cancellation propagate
} catch (e: Exception) {
logger.error("Unexpected error", e)
}
Use Supervisor for Independent Children
// Default behaviour – one child failure cancels the rest
coroutineScope {
launch { taskA() }
launch { taskB() }
}
// Supervisor – children fail independently
supervisorScope {
launch { taskA() }
launch { taskB() }
}
Ensure Proper Cleanup
val conn = dataSource.getConnection()
try {
// use the connection
} finally {
conn.close()
}
// or the idiomatic Kotlin form
dataSource.getConnection().use { conn ->
// work
}
Move Blocking Calls Off Default Dispatcher
withContext(Dispatchers.IO) {
Thread.sleep(5000) // safe here
jdbcTemplate.query("SELECT …") // safe here
}
Runtime Inspection and Commands
Enable Coroutine Debug Names
Start the JVM with -Dkotlinx.coroutines.debug=on. This adds coroutine names to stack traces and thread dumps, making the Job.parent chain visible. Use it on a staging instance first; it incurs a small runtime cost.
Print Thread and Coroutine State
jcmd <pid> Thread.print
Replace <pid> with the process ID (e.g., from jps). The output will show thread names like Dispatchers.Default-worker-… and, if debugging is enabled, coroutine names. Look for orphaned jobs or jobs that still run after cancellation.
Static Analysis
./gradlew detekt
Detekt includes rules such as GlobalScopeUsage that flag unstructured launches. Run this before merging code to catch violations early.
Integration Test with TestScope
import kotlinx.coroutines.test.*
@Test
fun testCancellationPropagates() = runTest {
val child = launch { delay(1000) }
cancel() // cancel the parent TestScope
assertTrue(child.isCancelled)
}
Running this test verifies that the child job observes cancellation. If the assertion fails, the coroutine hierarchy is broken.
Escalation Criteria
- If a heap dump shows a
JoborDeferredretaining a large outer object (e.g., a request cache), consider scoping the coroutine to the component’s lifecycle. - If a production hang persists after moving blocking calls to
Dispatchers.IO, profile with Java Flight Recorder and look for threads stuck inparkNanosorparkstates. - If uncaught exceptions from
launchstill crash the JVM, install aCoroutineExceptionHandleron the top‑level scope and log the exception.
Limitations and Practical Verification
Static analysis cannot catch every runtime violation, especially when coroutine hierarchies are built dynamically. Pair the diagnostic checks with unit tests that cancel a parent CoroutineScope and assert that all children are cancelled. For resource leaks, use a connection pool that reports idle connections; a leak will appear as a connection that never returns to the pool after cancellation.
Always run the diagnostics on a staging environment that mirrors production traffic. Enable coroutine debugging only for a short time to avoid performance impact on live traffic.