skip to content

What is the practical difference between a coroutine launched as a structured child versus one given its own root Job, and why does it matter for lifecycle?

level: seniorimportance: should knowfreq 40%

answer

  1. Structured = in the tree; detached = own root Job
  2. Detached: no await, no cancel, no failure propagation
  3. launch(Job()) / CoroutineScope(Job()) / GlobalScope detach
  4. Detached work outlives its visual scope = leak
  5. Legit detach = a longer-lived OWNED scope, not ad-hoc Job()

basics

~20 s

A structured child is tracked by its parent: the parent waits for it and cancels it. A coroutine given its own fresh Job becomes independent, so the parent neither waits for it nor cancels it — risking leaks.

solid answer

~40 s

A structured child inherits the surrounding Job as its parent, so it participates in the tree: the parent stays in Completing until the child finishes, cancelling the parent cancels the child, and the child's failure propagates up. If you instead pass a new Job() (e.g. launch(Job()) or build a CoroutineScope(Job())), the coroutine becomes a root with no parent link to the original scope: the scope won't await it, won't cancel it, and won't see its failures. That is sometimes intentional for genuinely independent background work tied to a longer-lived scope, but accidentally it produces leaked coroutines that outlive their logical owner. The fix is to launch within a properly scoped CoroutineScope whose Job you cancel at the right lifecycle boundary, rather than detaching ad hoc.

code

kotlin · 11 lines
kotlin
// Leaky: detached from the request scope
suspend fun handle() = coroutineScope {
    launch(Job()) { slowAudit() } // NOT awaited or cancelled by this scope
} // returns before slowAudit finishes; work leaks

// Better: own it under an app-scoped, cancelable scope
class AppScope {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    fun audit(block: suspend () -> Unit) = scope.launch { block() }
    fun shutdown() = scope.cancel()
}

go deeper

for a junior

Recognizes that some coroutines are tied to a scope and some are not.

for a middle

Explains that passing a fresh Job() detaches a coroutine so the scope no longer awaits or cancels it.

for a senior

Weighs leak risk vs intentional independence and prescribes owned long-lived scopes over ad-hoc detachment.

for a principal

Designs lifecycle ownership boundaries across an app so every coroutine has a clear, cancelable owner and no orphaned work.

## Two shapes **Structured child** — the normal case: ```kotlin scope.launch { // child of scope's Job launch { work() } // grandchild, tracked } ``` Every coroutine is a node in the tree rooted at `scope`'s `Job`. The guarantees apply: parent awaits children, cancel flows down, failure flows up. **Detached / re-rooted** — pass an explicit `Job`: ```kotlin scope.launch(Job()) { work() } // NEW root, not a child of scope val other = CoroutineScope(Job()) // independent scope/root other.launch { work() } ``` Now the coroutine's parent is the `Job()` you supplied, not `scope`'s Job. The link to `scope` is gone. ## What you lose when detached | Aspect | Structured child | Own root Job | |---|---|---| | Parent awaits it | Yes | No | | Cancelling parent cancels it | Yes | No | | Its failure propagates to parent | Yes | No | | Risk of leak | Low | High if unmanaged | A detached coroutine **outlives** the scope it visually sits in. If that scope was tied to a screen, request, or session, the work keeps running after the owner is gone — a classic leak, and on Android a memory leak holding references. ## When detaching is legitimate Sometimes you genuinely want work that survives the current operation — e.g. fire an audit log that should complete even if the request scope ends. The correct pattern is **not** an ad-hoc `Job()`, but launching into a **deliberately longer-lived, owned scope** (e.g. an application-scoped `CoroutineScope` you cancel on shutdown). That keeps it structured under *some* owner instead of orphaned. ## Diagnosing - If `coroutineScope { }` returns but background work keeps running, something inside detached itself. - Grep for `launch(Job())`, `async(Job())`, `CoroutineScope(Job())`, and `GlobalScope` — these are the usual detaching constructs. `GlobalScope` in particular has no lifecycle owner at all. ## Takeaway Detaching trades the safety net of structured concurrency for independence. Do it only when an explicit, owned lifecycle replaces the parent link — never by accident.

  • Why is GlobalScope discouraged in this context?
    GlobalScope has no lifecycle owner, so coroutines launched in it are never automatically cancelled and easily leak. Prefer an owned CoroutineScope you cancel at a real boundary.
  • Is detaching ever the right call?
    Yes, for work that must outlive the current operation (e.g. logging/audit), but only under a deliberately long-lived, owned scope that you cancel on shutdown — not an ad-hoc Job().

A structured child is an employee on the org chart; giving it its own Job() is like a contractor who keeps working even after the project (and its budget) is closed.

saying these in an interview costs you the question

  • Reaching for GlobalScope to 'just run something in the background'
  • Using launch(Job()) to silence a cancellation without understanding it detaches
  • Assuming a detached coroutine is still cancelled with its visual scope
  • Not recognizing detached work as a leak source
  • Thinking structured vs detached is purely stylistic with no runtime difference

context