skip to content

What does isActive return when read outside any coroutine, and why can that bite you when extracting CPU loops into helper functions?

level: seniorimportance: nice to knowfreq 25%

answer

  1. No Job in context => isActive is true
  2. Plain helper loses coroutine context
  3. Make CPU helpers suspend
  4. Or use a CoroutineScope receiver
  5. Check must bind to the running Job

basics

~10 s

If there is no Job in scope, isActive returns true. So a loop moved into a plain function with no coroutine context will think it is always active and never stop on cancel.

solid answer

~40 s

`isActive` is an extension on `CoroutineScope`/`CoroutineContext` that reads the `Job`'s state. When you read it where **no Job is present** — e.g. a top-level extension on a `CoroutineScope` you constructed wrongly, or inside a plain non-suspend helper that captured no context — it defaults to `true`. The danger appears when you extract a CPU loop into a helper: if the helper is a plain function (not `suspend`, no `CoroutineScope` receiver), `coroutineContext` isn't available, so people sometimes reach for the wrong `isActive` or hardcode `while (true)`, and cancellation silently stops working. Fixes: make the helper `suspend` (then use `coroutineContext.isActive`/`ensureActive()`/`yield()`), or give it a `CoroutineScope` receiver and use its `isActive`, or pass a cancellation check in. The general lesson: cancellation checks only work when bound to the *actual* running coroutine's Job.

code

kotlin · 10 lines
kotlin
// GOOD: suspend helper keeps the Job via coroutineContext
suspend fun crunch(total: Long): Long {
    var acc = 0L
    for (i in 0 until total) {
        currentCoroutineContext().ensureActive()  // real cancel check
        acc += i
    }
    return acc
}
// launch { crunch(1_000_000) } now cancels properly

go deeper

for a junior

Likely unaware isActive defaults to true outside a coroutine.

for a middle

Knows isActive needs a Job and that suspend helpers carry context.

for a senior

Explains the default-true trap, the extraction failure mode, and the suspend / receiver / passed-check fixes.

for a principal

Establishes API conventions so cancellable compute is always suspend or scope-receiver, and reviews for ambient-isActive misuse.

## Two `isActive`s and the default - `CoroutineScope.isActive` and `CoroutineContext.isActive` read `this[Job]?.isActive`. - The implementation returns **`true` when there is no `Job`** in the context (`coroutineContext[Job]?.isActive ?: true`). The idea: "no job ⇒ nothing to cancel ⇒ treat as active." That default is the trap. ## How extraction breaks cancellation ```kotlin launch { runLoop() } // we cancel this later fun runLoop() { // plain function: no suspend, no CoroutineScope receiver while (true) { /* work */ } // nothing checks cancellation } ``` The helper has no access to the coroutine's `Job`. If someone naively tries to add a check, they may reference an `isActive` that resolves to `true` (no Job) or fall back to `while (true)` — and cancellation silently does nothing. ## Correct extractions **Make it `suspend`** (carries `coroutineContext`): ```kotlin suspend fun runLoop() { while (currentCoroutineContext().isActive) { /* work */ } // or: ensureActive() / yield() } ``` **Give it a `CoroutineScope` receiver:** ```kotlin fun CoroutineScope.runLoop() { while (isActive) { /* work */ } // isActive bound to this scope's Job } // call: launch { runLoop() } ``` **Pass a check function** when neither fits: ```kotlin fun runLoop(isActive: () -> Boolean) { while (isActive()) { /* work */ } } ``` ## Why it matters - Cancellation only works when the check is bound to the **running coroutine's `Job`**. - A `suspend` function automatically captures that via `coroutineContext`; a plain function does not. - Reading `isActive` with no `Job` gives a false `true`, masking the bug at compile time. ## Rule of thumb > Cancellable CPU helpers should be `suspend` (use `currentCoroutineContext().ensureActive()` / `yield()`) or take a `CoroutineScope` receiver — never a plain function relying on an ambient `isActive`.

  • Why does Kotlin default isActive to true with no Job?
    Semantically, no Job means there's nothing that can be cancelled, so the context is treated as active. It's a convenience that unfortunately hides extraction bugs.
  • How does making the helper suspend fix it?
    A suspend function has access to coroutineContext at the call site's coroutine, so currentCoroutineContext()/ensureActive()/yield() see the real Job and its cancellation state.

saying these in an interview costs you the question

  • Assuming isActive throws or is false when no Job exists
  • Extracting loops into plain functions and expecting cancellation
  • Using a fresh CoroutineScope() inside the helper just to read isActive
  • Hardcoding while (true) in extracted compute helpers
  • Not binding the check to the running coroutine's Job

context