skip to content

Scope Lifecycle & Leak Prevention

Owning a scope means constructing it with a SupervisorJob and a dispatcher, and cancelling it when the component it belongs to dies. Interviewers ask this as the leak question: who cancels your scope, and when.

part ofKotlinoverview, primer and where to startread it →
on this pageshow

questions

5

Why does an object with a lifecycle (e.g. a ViewModel, a service, a request handler) usually own its own CoroutineScope, and what does cancelling that scope accomplish?

level: juniorimportance: must knowfreq 70%

answer

  1. Scope = Job + Dispatcher
  2. launch/async become children of the scope's Job
  3. scope.cancel() cancels Job -> all children stop
  4. Create on init, cancel on teardown
  5. GlobalScope outlives owner = leak

basics

~20 s

The object creates a scope to launch background work tied to its life. When the object is done, it cancels the scope, which stops every coroutine started in it so nothing keeps running and leaking.

solid answer

~40 s

A CoroutineScope groups the coroutines an owner starts (via launch/async). Because of structured concurrency, every coroutine launched in the scope becomes a child of the scope's Job. When the owner reaches end-of-life, it calls scope.cancel(): this cancels the scope's Job, which propagates cancellation to all child coroutines, so in-flight work stops and resources (network calls, timers, collectors) are released. Without an owned scope you might use GlobalScope or a detached launch, and those coroutines outlive the owner — a leak. Owning the scope ties coroutine lifetime to object lifetime: create the scope when the owner is created, cancel it in the owner's teardown hook (onCleared, close(), @PreDestroy). Use CoroutineScope(SupervisorJob() + dispatcher) so one failing child does not tear down the others.

code

kotlin · 11 lines
kotlin
class FeedService {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    fun start() {
        scope.launch { pollUpdates() }   // child of scope's Job
    }

    fun shutdown() {
        scope.cancel()                    // stops pollUpdates() and any sibling
    }
}

go deeper

for a junior

Knows you create a scope, launch into it, and call scope.cancel() at the end to avoid leaks.

for a middle

Explains the parent/child Job tree and that cancel() propagates downward to free resources; contrasts with GlobalScope.

for a senior

Ties scope lifetime precisely to owner lifetime, picks SupervisorJob + dispatcher deliberately, and reasons about which teardown hook to cancel in.

for a principal

Frames owned scopes as a leak-prevention and resource-ownership policy across a codebase, and standardizes the create/cancel discipline in shared infrastructure.

## What a CoroutineScope is A `CoroutineScope` is just an object that carries a `CoroutineContext` — most importantly a `Job` and a `CoroutineDispatcher`. Every coroutine builder (`launch`, `async`) is an extension on `CoroutineScope`, so calling `scope.launch { ... }` starts a coroutine *inside* that scope. The new coroutine's `Job` becomes a **child** of the scope's `Job`. ## Why an owner should hold a scope **Structured concurrency** means coroutines form a parent/child tree and a parent does not finish (and can be cancelled) along with its children. An object that starts background work — an Android `ViewModel`, a Spring service, a connection handler — has a clear lifetime. By giving it its own scope, every coroutine it starts is anchored to that lifetime. - **Create** the scope when the owner is created: ```kotlin private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) ``` - **Use** it to launch work: `scope.launch { repository.refresh() }`. - **Cancel** it in the owner's teardown: `scope.cancel()`. ## What scope.cancel() does `scope.cancel()` cancels the scope's `Job`. Cancellation **propagates downward** to every child coroutine: each child's `Job` is cancelled, suspension points throw `CancellationException`, and the coroutines stop at their next cancellation check. This frees whatever they held — open sockets, database cursors, `Flow` collectors, delays. This is the core leak-prevention mechanism: one call shuts down a whole subtree. ## What a leak looks like without it If you instead use `GlobalScope.launch { ... }`, the coroutine is tied to the *application*, not your object. When your object is destroyed the coroutine keeps running, holding references (often the destroyed object itself), never to be cleaned up. That is a coroutine leak. ## Key APIs/keywords - `CoroutineScope(...)` — factory that builds a scope from a context. - `SupervisorJob()` — a `Job` where a child's failure does not cancel siblings. - `Dispatchers.Default` / `Dispatchers.IO` / `Dispatchers.Main` — the dispatcher choosing the thread pool. - `scope.cancel()` — cancels the scope's Job and all children. - `launch` / `async` — builders that create child coroutines. The rule of thumb: *whoever creates the scope is responsible for cancelling it.*

  • What happens to coroutines you launched if you forget to call scope.cancel()?
    They keep running (or stay suspended) and hold references to the owner, leaking memory and possibly doing useless work; nothing automatically stops them.
  • Who is responsible for cancelling the scope?
    The component that created/owns the scope, in its lifecycle teardown hook (e.g. onCleared(), close(), @PreDestroy).

The scope is a power strip: plug devices (coroutines) into it; flip the strip's switch (cancel) and everything plugged in turns off at once.

saying these in an interview costs you the question

  • Thinks coroutines stop on their own when the object is garbage-collected
  • Defaults to GlobalScope for object-scoped work
  • Cannot explain that cancel() propagates to children
  • Confuses creating a scope with creating a thread
  • Never cancels the scope anywhere

context

open as a page

When constructing CoroutineScope(SupervisorJob() + dispatcher) for a long-lived owner, why is SupervisorJob preferred over a plain Job, and what is the consequence at the scope level?

level: middleimportance: must knowfreq 62%

basics

~10 s

With a plain Job, one child crashing cancels the whole scope and its siblings. With SupervisorJob, a child's failure stays local — siblings keep running and the scope stays alive to launch more work.

open as a page

scope.cancel() is called but a child coroutine keeps running. Explain why coroutine cancellation is cooperative, and how to make CPU-bound or blocking code actually stop.

level: seniorimportance: must knowfreq 55%

basics

~10 s

Cancellation only takes effect at suspension or check points. A tight loop or a blocking call never checks, so it keeps running. You make it stop by suspending, checking isActive, or calling ensureActive()/yield().

open as a page

What is the difference between scope.cancel() and scope.coroutineContext.cancelChildren() for a scope you own, and when would you choose each?

level: middleimportance: should knowfreq 40%

basics

~10 s

cancel() cancels the scope's Job itself, so the scope is dead and can't be reused. cancelChildren() stops the current coroutines but leaves the Job alive, so you can launch new work afterward.

open as a page

You own a CoroutineScope(SupervisorJob() + Dispatchers.IO) in a server component and need a graceful shutdown that stops accepting new work and waits for in-flight coroutines to finish (with a timeout) before the process exits. How do you implement it?

level: principalimportance: should knowfreq 28%

basics

~10 s

Stop launching new work, cancel the children, then wait for the scope's Job to finish using join with a timeout. If it overruns, force-cancel and exit. Cancel the scope itself last.

open as a page