LaunchedEffect Stale Value: Why LaunchedEffect(Unit) Calls an Old Callback

A LaunchedEffect stale value comes from two captures stacked on top of each other. First, LaunchedEffect(Unit) starts its coroutine once. The block keeps the onTimeUp lambda it saw in the first composition. Recomposition passes in newer lambdas. The running block never sees them. Second, the parent built that lambda as { onSubmit(answer) }, with answer as a plain String parameter. A lambda copies a plain value when it’s created. So the first lambda holds the empty string forever. Before the state was hoisted, answer was a delegated mutableStateOf. The lambda read it on every call. That hid the first bug. The fix keeps the timer running and refreshes the callback. Read it through rememberUpdatedState(onTimeUp) inside the effect. Then key the effect on what should restart it. Here that’s the question ID. Don’t key it on the callback. A new lambda arrives with every keystroke, so the timer would restart each time.

A 60-second question timer that submits an empty answer

The running example is a timed coding question. The candidate types an answer. After 60 seconds the timer submits whatever is in the box. The screen is stateless, because the answer was hoisted to the ViewModel. Here’s the code a candidate gets handed in a bug-squash round.

@Composable
fun QuestionTimer(seconds: Int, onTimeUp: () -> Unit) {
    LaunchedEffect(Unit) {
        delay(seconds * 1_000L)
        onTimeUp()
    }
}

@Composable
fun QuestionScreen(
    questionId: String,
    answer: String,
    onAnswerChange: (String) -> Unit,
    onSubmit: (String) -> Unit,
) {
    QuestionTimer(seconds = 60, onTimeUp = { onSubmit(answer) })
    OutlinedTextField(value = answer, onValueChange = onAnswerChange)
    Button(onClick = { onSubmit(answer) }) { Text("Submit") }
}

The candidate types “use a Set” and waits. At 60 seconds the ViewModel receives an empty string. The text field shows the answer the whole time. The Submit button sends the right text, too. Only the timer is wrong. Nothing crashes and nothing logs. So the search tends to start in the ViewModel. That’s the wrong layer.

A LaunchedEffect stale value starts with the Unit key

The Compose side-effects guide states the restart rule. The coroutine is canceled and relaunched only when LaunchedEffect recomposes with different keys. Unit never changes. So the block launched in the first composition is the only one that ever runs.

That block is a lambda. It captured the onTimeUp parameter as it was at that moment. QuestionTimer does recompose on every keystroke. Each time, it receives a new onTimeUp. But that new value goes into a block that LaunchedEffect ignores, because the key didn’t change. The running coroutine still holds the first lambda.

The Button doesn’t have this problem. onClick is replaced on every recomposition. A tap calls the current one. The timer is the only consumer that outlives a composition.

The same guide warns that LaunchedEffect(true) is as suspicious as a while(true). It also gives a rule for variables used in an effect. Each one should be a key or go through rememberUpdatedState. This code does neither.

Hoisting the answer turned a state read into a copied String

A stale first lambda alone isn’t enough to lose the answer. The lambda also has to hold an old value. Before the refactor, the screen owned its state:

var answer by rememberSaveable { mutableStateOf("") }
QuestionTimer(seconds = 60, onTimeUp = { onSubmit(answer) })

That version submitted the right text with the same LaunchedEffect(Unit). answer there is a local delegated property. A lambda that uses it captures the delegate. The delegate is the MutableState object. Each read of answer calls getValue at that moment. So the first lambda still read the latest text.

Hoisting changed answer into a String parameter. A lambda captures a plain val by its value. The first lambda now holds "". This plain Kotlin program shows the difference. FakeState stands in for MutableState:

import kotlin.reflect.KProperty

class FakeState(var v: String) {
    operator fun getValue(t: Any?, p: KProperty<*>) = v
    operator fun setValue(t: Any?, p: KProperty<*>, n: String) { v = n }
}

fun main() {
    var answer by FakeState("")   // like var answer by mutableStateOf("")
    val delegated = { answer }    // captures the delegate
    val plain = answer            // like a hoisted answer: String parameter
    val snapshot = { plain }      // captures the value ""
    answer = "use a Set"
    println("delegated lambda -> \"${delegated()}\"")
    println("plain-val lambda -> \"${snapshot()}\"")
}

Compiled with kotlinc 2.4.20, it prints this:

delegated lambda -> "use a Set"
plain-val lambda -> ""

The Unit key was wrong from the start. The delegated read covered for it until the refactor moved the state up a level. The same delegated read causes a different Compose bug. See the null check that didn’t save you.

Why every keystroke produces a new onTimeUp

The fix depends on how often onTimeUp changes. Strong skipping mode decides that. It’s on by default from Kotlin 2.0.20, per the strong skipping docs. The compiler wraps every lambda in a composable in a remember. That remember is keyed on the lambda’s captures.

{ onSubmit(answer) } captures onSubmit and answer. Both are keys of that hidden remember. answer changes with each keystroke. So QuestionTimer gets a new lambda each time the candidate types. A lambda built from changing state is new whenever that state changes.

That rules out keying the effect on onTimeUp. We modeled the effect’s key check and the lambda memo in plain Kotlin with kotlinx.coroutines. Four keystrokes arrived 300 ms apart against a 1-second timer. With the Unit key, the model submitted "" at about 1,000 ms. Keyed on onTimeUp, it started the effect five times. It submitted “use a Set” at about 2,200 ms. The timer had moved its deadline with every keystroke. That model reproduces the documented rules. It isn’t Compose itself.

Key the effect on the question and read the callback through rememberUpdatedState

The fix separates two jobs. A new question should restart the timer. A new callback should only replace the one the timer will call. Each job gets its own tool.

@Composable
fun QuestionTimer(questionId: String, seconds: Int, onTimeUp: () -> Unit) {
    val currentOnTimeUp by rememberUpdatedState(onTimeUp)

    LaunchedEffect(questionId, seconds) {
        delay(seconds * 1_000L)
        currentOnTimeUp()
    }
}

// in QuestionScreen
QuestionTimer(questionId = questionId, seconds = 60, onTimeUp = { onSubmit(answer) })

This follows the LandingScreen sample in the side-effects guide. The implementation is small. In SnapshotState.kt, rememberUpdatedState remembers one mutableStateOf. Then it writes the new value into it on every composition. The effect block captures that one State through the by delegate. So the call at 60 seconds reads the newest lambda. That lambda carries the newest answer.

The delegated read in the old stateful screen did the same job by accident. rememberUpdatedState does it on purpose, at the place that needs it. The parent can now pass any lambda it likes, built from plain values or state.

The questionId key covers a case the old code also got wrong. Suppose the same QuestionScreen stays in composition and shows question 2. With Unit, question 2 never gets its own 60 seconds. With questionId, the old coroutine is canceled and a fresh timer starts. In the model, the fixed version submitted “use” at about 1,000 ms. That was the text in the box when time ran out. The effect started once.

Android and Compose APIs can’t compile outside an Android project. So this fix hasn’t been run on a device here. Its behavior rests on the guide and the runtime source above.

The explanation that gets credit in a bug-squash round

This snippet works in a technical round or a bug-squash round. It’s a good “second bug” because the obvious first fix makes it worse. Our Android debugging interview post covers how that round is scored. Say it in this order.

  1. Locate it. The text field and the button are right, so the state is fine. Only the long-lived consumer is wrong. That’s the effect.
  2. Name the first capture. LaunchedEffect(Unit) launches once. Its block holds the onTimeUp from the first composition. A new value only matters when a key changes.
  3. Name the second capture. answer is a plain String parameter, so that first lambda copied "". A delegated mutableStateOf would have been read at call time. That’s why it worked before the state was hoisted.
  4. Reject the obvious fix. Keying on onTimeUp restarts the timer on every keystroke. Strong skipping remembers the lambda by its captures. answer is one of them.
  5. Give the fix. Key the effect on questionId. Call the callback through rememberUpdatedState.

“Use rememberUpdatedState” is the line people memorize. Point 3 goes further. It explains why the bug appeared only after a refactor. That shows you know what a lambda captures.

Answers that sound right and leave the timer broken

  • “Add onTimeUp to the keys.” The answer is fresh. But the timer never expires while the candidate keeps typing. A 60-second limit becomes 60 seconds after the last keystroke.
  • “Key it on answer.” This fails the same way, for the same reason. It also makes the timer depend on a value it doesn’t use.
  • “Wrap it in remember { onTimeUp }.” remember without keys stores the first value and returns it forever. That freezes the stale lambda in place.
  • “Pass answer into QuestionTimer and submit it there.” The parameter is captured the same way. The timer also takes on a job that belongs to the screen.
  • “The composable isn’t recomposing.” It is. QuestionTimer recomposes on every keystroke. The effect doesn’t restart. That’s documented behavior.

Events can also get lost in a Compose effect without going stale. See why one-time events in Jetpack Compose keep breaking.

Leave a Comment