Inside Compose Side Effects: RememberObserver and the Composition Lifecycle
Inside Compose Side Effects: RememberObserver and the Composition Lifecycle
Open almost any Compose screen and the effect handlers are already there. You write LaunchedEffect(userId) { viewModel.load(userId) } to kick off a load when the id changes. You write DisposableEffect(owner) { owner.register(cb); onDispose { owner.unregister(cb) } } to attach and clean up a listener. You call rememberCoroutineScope() so a button onClick can launch a coroutine. You drop a SideEffect { analytics.setCurrentScreen(name) } to keep something outside Compose in sync. These four are muscle memory. You reach for them without a second thought about what each one costs.
Most developers picture LaunchedEffect as "run this block when the key changes." That mental model is not wrong, but it hides what the runtime actually does. Every time the key changes, Compose cancels a running coroutine and launches a brand new one. There is a real CoroutineScope, a real Job, and a real coroutine behind every LaunchedEffect, whether or not your block ever suspends. If your effect is a synchronous call that just needs to react to a key, you are paying for a coroutine you never asked for. To see why, and to know when a lighter tool is the better choice, you have to follow these functions down to a single interface that almost all of them share: RememberObserver.
In this article, you'll dive deep into how Compose effect handlers work, exploring why an effect needs a well defined place in the composition lifecycle, how remember turns a key change into a fresh object, how LaunchedEffectImpl launches and cancels a coroutine as that object enters and leaves the composition, where the effect coroutine's Job and frame clock come from, how DisposableEffect, SideEffect, and rememberCoroutineScope each differ, how the RememberEventDispatcher fires onRemembered, onForgotten, and onAbandoned during applyChanges, and when a coroutine free effect such as the compose-effects library's RememberedEffect avoids the cost entirely.
The fundamental problem: running imperative code inside a declarative tree
Composition is declarative and repeatable. A composable function can run once, then re-run on the next recomposition, then run again, with no fixed schedule. That is fine for describing UI, but it collides with imperative work that must happen a controlled number of times. Consider the most direct way to start a load:
@Composable
fun UserScreen(userId: String, viewModel: UserViewModel) {
viewModel.load(userId) // runs on every single recomposition
val user by viewModel.user.collectAsStateWithLifecycle()
UserContent(user)
}
viewModel.load(userId) runs every time UserScreen recomposes, which could be dozens of times per second while an animation is running elsewhere. You wanted it to run once per userId, not once per recomposition. You also have nowhere to put cleanup: if the screen leaves the tree, there is no hook to cancel an in flight request.
Effect handlers exist to give imperative code a defined position in the composition lifecycle. They answer three questions the naive call cannot: run how often, clean up when, and what happens if the composition that scheduled the work is thrown away before it commits. All of the answers come from the same place, so it is worth meeting that place first.
The four handlers, and the one interface beneath them
There are four effect handlers you reach for daily. LaunchedEffect runs a suspend block in a coroutine, keyed, and restarts it when a key changes. DisposableEffect runs setup code, keyed, and runs a matching teardown when a key changes or the effect leaves. SideEffect runs a plain block after every successful composition. rememberCoroutineScope hands you a CoroutineScope you can launch into from outside composition, such as a click handler.
Three of the four are the same shape underneath. They are a remember call that produces an object implementing RememberObserver, and that interface is where the lifecycle actually lives:
public interface RememberObserver {
public fun onRemembered()
public fun onForgotten()
public fun onAbandoned()
}
The contract is small and precise. onRemembered is called, on the composition's apply thread, when the object has been committed to the slot table for a composition. onForgotten is called, also on the apply thread, when the object is no longer remembered anywhere, because its group left the tree or the composition was disposed. onAbandoned is called instead of onRemembered when the object was produced by a remember calculation during a composition that was never applied. The KDoc adds two guarantees that matter later: an object remembered in one place receives either onRemembered or onAbandoned but never both, and when several objects are remembered together their onForgotten calls run in the reverse order of their onRemembered calls.
SideEffect is the exception that will make the rule clearer, so it comes later. The other three are variations on one theme: put a RememberObserver in the slot table with remember, and let its three callbacks do the work.
remember is the real primitive
Here is the observation that reframes everything. The effect functions carry almost no logic of their own. LaunchedEffect(key1) is, in full:
@Composable
@NonRestartableComposable
public fun LaunchedEffect(key1: Any?, block: suspend CoroutineScope.() -> Unit) {
val applyContext = currentComposer.applyCoroutineContext
remember(key1) { LaunchedEffectImpl(applyContext, block) }
}
That is the whole function. It captures a coroutine context, then calls remember(key1) with a factory that builds a LaunchedEffectImpl. Every behavior you associate with LaunchedEffect, including "restart when the key changes," is really the behavior of remember(key1). So you need to know what remember does with a key.
Under the hood, remember is Composer.cache, which reads the value currently stored in this slot and decides whether to keep it or recompute:
public inline fun <T> Composer.cache(invalid: Boolean, block: () -> T): T {
return rememberedValue().let {
if (invalid || it === Composer.Empty) {
val value = block()
updateRememberedValue(value)
value
} else it
} as T
}
remember(key1) { ... } calls this with invalid = currentComposer.changed(key1), which is true when the key differs from the value stored last time. So the logic is: if the key changed, or nothing is stored yet, run the factory and store the new value; otherwise return the cached value untouched. A changed key produces a brand new object, and the previously stored object is scheduled to leave the composition. This single fact is the hinge of the article. A key change does not "re-run your effect." It produces a new RememberObserver and forgets the old one, and the effect's behavior falls out of what those two objects do in onRemembered and onForgotten.
LaunchedEffect dissected: the coroutine behind the curtain
Now look at the object remember is storing. LaunchedEffectImpl is a RememberObserver, and its callbacks are where the coroutine appears:
internal class LaunchedEffectImpl(
private val parentCoroutineContext: CoroutineContext,
private val task: suspend CoroutineScope.() -> Unit,
) : RememberObserver, CoroutineExceptionHandler {
private val scope = CoroutineScope(parentCoroutineContext + this)
private var job: Job? = null
override fun onRemembered() {
job?.cancel("Old job was still running!")
job = scope.launch(block = task)
}
override fun onForgotten() {
job?.cancel(LeftCompositionCancellationException())
job = null
}
override fun onAbandoned() {
job?.cancel(LeftCompositionCancellationException())
job = null
}
}
The class builds a CoroutineScope in its constructor and holds a nullable Job. When the composition remembers it, onRemembered launches task into that scope and stores the resulting Job. When the composition forgets or abandons it, the Job is cancelled with a LeftCompositionCancellationException and cleared. The job?.cancel("Old job was still running!") line in onRemembered is defensive and normally a no op, since a fresh impl has no job yet. The class also implements CoroutineExceptionHandler and adds itself to its own scope context, which the source notes is done to save an allocation rather than create a separate handler object.
Combine this with the remember behavior from the previous section and the key change story is complete. Suppose LaunchedEffect(userId) { load(userId) } and userId goes from A to B:
- On the recomposition,
remember(B)compares the stored keyAagainstB, finds them different, and runs the factory. That produces a newLaunchedEffectImplwith its own fresh scope. The old impl is scheduled to be forgotten. - When the composition applies, the runtime calls
onForgottenon the old impl, which cancels the coroutine still runningload(A), and callsonRememberedon the new impl, which launches a new coroutine runningload(B).
So "restart the effect when the key changes" is, mechanically, "cancel the old coroutine and launch a new one," driven entirely by remember producing a different object identity. The LaunchedEffect function itself never looks at the key. When the screen leaves the tree entirely, only onForgotten runs, the coroutine is cancelled, and nothing relaunches. That is why an in flight LaunchedEffect is torn down automatically when its composable disappears.
Where the Job and the frame clock come from
The scope is built from parentCoroutineContext + this, and the impl never adds a Job() of its own. That raises a question: what parents the launched coroutine, and why can you call withFrameNanos or animate* inside a LaunchedEffect with no extra setup? The answer is in the context the effect captured with currentComposer.applyCoroutineContext.
That context is assembled by the Recomposer. Its effect context is the user supplied context plus two elements it adds:
override val effectCoroutineContext: CoroutineContext =
effectCoroutineContext + broadcastFrameClock + effectJob
The broadcastFrameClock is a MonotonicFrameClock, and effectJob is a Job that parents all effect work. Because applyCoroutineContext already contains this Job, the standard CoroutineScope(context) factory does not append another one, so the effect coroutine's Job becomes a child of the recomposer's effectJob. Two consequences follow. Cancelling the whole recomposer, which happens when the composition is torn down, cancels every effect coroutine through this parent child link. And because the frame clock is an element of the context, a suspend call like withFrameNanos finds it automatically:
public suspend fun <R> withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R =
coroutineContext.monotonicFrameClock.withFrameNanos(onFrame)
This is why animations written inside LaunchedEffect line up with the display without any wiring on your part. The frame clock rides in on the same context that carries the cancellation Job.
The hidden cost, and when you do not need it
Everything above is exactly what you want when the effect is asynchronous. Collecting a flow, calling a suspend network function, running delay, or animating all need a coroutine, a cancellation Job, and the frame clock, and LaunchedEffect provides them for free. The cost only becomes waste when the block never suspends. Consider a very common shape:
LaunchedEffect(count) {
analytics.log("count changed to $count") // synchronous, never suspends
}
You wanted "run this line whenever count changes." What you got is a LaunchedEffectImpl allocation, a CoroutineScope, and, on every key change, a coroutine cancellation followed by a coroutine launch, all to run one synchronous statement. The coroutine starts, the block runs to completion without ever suspending, and the coroutine ends. None of the machinery earned its keep.
The lighter tool is a RememberObserver that simply runs your block in onRemembered, with no scope and no Job. That is precisely what the compose-effects library provides with RememberedEffect. Its README frames the same tradeoff: LaunchedEffect suits coroutine tasks because it creates a new coroutine scope and relaunches the task on each key change, while RememberedEffect does not create or launch a scope per key change, which makes it a more efficient option for remembering the execution of side effects. The usage mirrors LaunchedEffect:
var count by remember { mutableIntStateOf(0) }
RememberedEffect(key1 = count) {
Log.d(tag, "$count")
}
This design follows naturally from everything you have seen. It is a remember(key1) that stores a RememberObserver whose onRemembered runs your block, reusing the exact key change mechanism from earlier without the coroutine. The decision rule is simple. If your block suspends, reach for LaunchedEffect, because you genuinely need the coroutine, its cancellation, and the frame clock. If your block is synchronous and only needs to react to a key, a coroutine free effect avoids the allocation and the churn of cancelling and relaunching. And as the next section shows, the runtime already ships one coroutine free effect that most codebases underuse.
DisposableEffect: setup and teardown without a coroutine
DisposableEffect is the coroutine free effect that has been in the runtime all along. Its impl is another RememberObserver, but its callbacks run plain code instead of touching coroutines:
private class DisposableEffectImpl(
private val effect: DisposableEffectScope.() -> DisposableEffectResult
) : RememberObserver {
private var onDispose: DisposableEffectResult? = null
override fun onRemembered() {
onDispose = InternalDisposableEffectScope.effect()
}
override fun onForgotten() {
onDispose?.dispose()
onDispose = null
}
override fun onAbandoned() {
// Nothing to do as [onRemembered] was not called.
}
}
On remember, it runs your effect and keeps the DisposableEffectResult it returns. On forget, it calls dispose() on that result. The DisposableEffectScope exists only to make the onDispose { } block return a result object:
public class DisposableEffectScope {
public inline fun onDispose(
crossinline onDisposeEffect: () -> Unit
): DisposableEffectResult =
object : DisposableEffectResult {
override fun dispose() { onDisposeEffect() }
}
}
Two details are worth pausing on. First, there is no coroutine anywhere in this path, which is why DisposableEffect is the right tool for registering and unregistering listeners, observers, or callbacks keyed to a value. Second, notice that onAbandoned does nothing, unlike LaunchedEffectImpl, whose onAbandoned still cancels its Job. The reason is symmetry with onRemembered. DisposableEffectImpl only acquires a resource in onRemembered, so if the effect was abandoned before ever being remembered there is nothing to release. LaunchedEffectImpl cancels defensively in onAbandoned even though its job is normally null, because cancelling a null job is harmless and the symmetry keeps the callback safe.
One ergonomic wrinkle is worth naming. DisposableEffect always requires you to return an onDispose { } result, even when there is nothing to tear down, so using it purely to react to a key means writing an empty onDispose { }. That is the gap a dedicated RememberedEffect fills: the same coroutine free reaction to a key, without the disposal ceremony.
SideEffect: the odd one out
SideEffect breaks the pattern, and seeing how makes the pattern clearer. It is not a RememberObserver, and it stores nothing in the slot table:
@Composable
@NonRestartableComposable
public fun SideEffect(effect: () -> Unit) {
currentComposer.recordSideEffect(effect)
}
There is no SideEffectImpl class. recordSideEffect appends the block to the composition's change list, and it runs during the apply phase after the remember callbacks. Because nothing is remembered, there are no keys and no cleanup hook, and the keyless SideEffect runs after every successful composition and recomposition. Its job is to publish a Compose value to an object outside Compose that must be kept in sync on each pass, such as setting a property on a view or a system service. There are keyed SideEffect overloads that gate the recording behind currentComposer.changed(keyN), but the common keyless form deliberately fires on every apply, not just once.
The contrast is the useful part. LaunchedEffect and DisposableEffect are remembered, so they have identity, keys, and a leaving callback. SideEffect is recorded, so it has none of those and simply runs at the end of each apply. If you have ever wondered why SideEffect has no cleanup and no keys while the others do, this is the answer: it never enters the slot table.
rememberCoroutineScope: a scope you drive yourself
The last handler gives you a coroutine scope without launching anything into it. Its definition is a remember with no keys:
@Composable
public inline fun rememberCoroutineScope(
crossinline getContext: () -> CoroutineContext = { EmptyCoroutineContext }
): CoroutineScope {
val composer = currentComposer
return remember { createCompositionCoroutineScope(getContext(), composer) }
}
Because there are no keys, the scope is created once at first composition and kept until the call site leaves the tree. The object it returns is a RememberedCoroutineScope, which is both a CoroutineScope and a RememberObserver. Its coroutine context is built lazily on first access, so a scope you never actually launch into costs almost nothing, and its onForgotten and onAbandoned cancel whatever was created. Its Job is a child of the same apply context Job you saw earlier, so it too is torn down with the composition.
The point of rememberCoroutineScope is what it does not do. It launches nothing on its own. You call scope.launch { } yourself, from an event callback like onClick, where there is no composition to attach the work to. LaunchedEffect, by contrast, launches as a side effect of composition. The source states the rule directly in its documentation: jobs should never be launched into a scope as a side effect of composition itself, and for ongoing jobs started by composition you should use LaunchedEffect. So the choice between them is not about style. LaunchedEffect is for coroutines whose lifetime is tied to a key being present in the composition, and rememberCoroutineScope is for coroutines started by user events whose lifetime is tied only to the call site remaining in the composition.
The composition lifecycle: how the callbacks actually fire
You now know what each effect does in its RememberObserver callbacks. The remaining question is how those callbacks get called at the right time. This is the composition lifecycle machinery, and it runs in three movements: recording during composition, dispatching during apply, and cleanup, which itself splits into abandoning a composition that never commits and disposing one that is torn down.
Recording: remember enqueues a holder and an abandon candidate
When a composable calls remember and the factory produces a value, the composer stores it through updateCachedValue. If that value is a RememberObserver, three things happen at once:
internal fun updateCachedValue(value: Any?) {
val toStore =
if (value is RememberObserver) {
val holder = GapRememberObserverHolder(value, rememberObserverGroupIndex())
if (inserting) {
changeListWriter.remember(holder)
}
abandonSet.add(value)
holder
} else value
updateValue(toStore)
}
The observer is wrapped in a RememberObserverHolder, a remember operation is appended to the change list, and, importantly, the raw observer is added to the composition's abandonSet immediately. That last step enrolls every freshly created effect as an abandon candidate before the runtime knows whether this composition will actually be applied. When a group is removed instead, the slot walk records the leaving observer by calling forgetting(holder). Side effects are recorded separately with recordSideEffect, which enqueues a side effect operation into the same change list.
Dispatching: forgotten in reverse, remembered forward, then side effects
At apply time, CompositionImpl hands the recorded changes to a RememberEventDispatcher, the rememberManager in the code below, which applies them and then dispatches the lifecycle callbacks. The order is deliberate:
applier.onBeginChanges()
changes.execute(slotStorage, applier, rememberManager, errorContext)
applier.onEndChanges()
rememberManager.dispatchRememberObservers()
rememberManager.dispatchSideEffects()
changes.execute applies the node and slot changes and, as it goes, records each observer into the dispatcher: remembered observers into a remembering list, forgotten ones into a unified leaving list, and side effects into a sideEffects list. Then dispatchRememberObservers fires the callbacks. Forgets run first, in reverse order, then remembers run forward:
fun dispatchRememberObservers() {
// Send forgets, in reverse order
for (i in leaving.size - 1 downTo 0) {
val instance = leaving[i]
if (instance is RememberObserverHolder) {
abandoning.remove(instance.wrapped)
instance.wrapped.onForgotten()
}
}
// Send remembers, in forward order
remembering.forEach { instance ->
abandoning.remove(instance.wrapped)
instance.wrapped.onRemembered()
}
}
Two ordering choices are visible here. Forgets run in reverse of remembers so that teardown mirrors setup: a child effect is forgotten before the parent whose group encloses it. And both loops call abandoning.remove(...) before invoking the callback, which is how an observer that is genuinely remembered or forgotten is taken off the abandon list. After this, dispatchSideEffects runs the recorded side effects. The comment in the source explains why side effects come last: a side effect that captured a remembered object must run after that object has received onRemembered, so the object is fully initialized before the side effect touches it.
Abandonment: the composition that never was
Whatever remains in abandonSet after the remember and forget dispatch was created during a composition that never committed. Those observers get onAbandoned:
fun dispatchAbandons() {
val iterator = abandoning.iterator()
while (iterator.hasNext()) {
val instance = iterator.next()
iterator.remove()
instance.onAbandoned()
}
}
This is the path for a composition that fails midway, or a subtree that is computed and then discarded before it is applied. A LaunchedEffectImpl created in such a composition never had onRemembered called, so it never launched a coroutine, and onAbandoned cancels a job that is almost always null. The abandon dispatch runs from the apply path when a composition is discarded, and also from a guard around composeContent and recompose so that a throwing composition still cleans up the observers it created. An abandoned observer receives neither onRemembered nor onForgotten, only onAbandoned, which is exactly the guarantee the RememberObserver KDoc promises.
Disposal: everything is forgotten
When a composition is disposed, the runtime walks the entire slot table and forgets every remembered observer:
if (nonEmptySlotTable || abandonSet.isNotEmpty()) {
rememberManager.use(abandonSet, errorContext) {
if (nonEmptySlotTable) {
applier.onBeginChanges()
slotStorage.clear(rememberManager)
applier.clear()
applier.onEndChanges()
dispatchRememberObservers()
}
dispatchAbandons()
}
}
slotStorage.clear(rememberManager) visits every slot and records each RememberObserver as forgetting, then dispatchRememberObservers fires all of their onForgotten callbacks in reverse order, with no remembers to balance them. This is the mechanism that guarantees a screen leaving the back stack cancels every LaunchedEffect coroutine it started and disposes every DisposableEffect resource it acquired. Nothing is left running.
A full trace: one key change, end to end
Earlier you saw the short version of this. Now that the dispatcher is on the table, here is the same key change with every step of the apply phase filled in. Take LaunchedEffect(userId) { load(userId) } where userId changes from A to B during a recomposition.
During composition, LaunchedEffect(B, block) runs. It calls remember(B), which compares the stored key A against B, finds them different, and invokes the factory, producing impl_B, a new LaunchedEffectImpl with its own scope. The old impl_A is scheduled to leave its slot, and impl_B is added to abandonSet and wrapped in a holder, with a remember operation enqueued.
During apply, changes.execute writes the slot changes and records impl_B into the dispatcher's remembering list and impl_A into the leaving list. Then dispatchRememberObservers runs. The forget loop calls impl_A.onForgotten\(\), which cancels the coroutine still running load\(A\). The remember loop calls impl_B.onRemembered(), which launches a new coroutine running load(B) and removes impl_B from abandoning. dispatchSideEffects finds nothing to run, and dispatchAbandons finds abandoning empty. The net result is that the old load was cancelled and a new load was launched, purely because remember produced a different object for the new key and the dispatcher forgot one observer and remembered another.
Every effect handler is a variation on this trace. DisposableEffect follows the identical path, with onForgotten disposing a resource instead of cancelling a coroutine. rememberCoroutineScope follows it with no keys, so the scope is remembered once and only ever forgotten. SideEffect sits outside it, recorded rather than remembered, and run at the end of each apply.
Real world patterns and gotchas
The internals turn several common bugs and choices into obvious consequences.
- Unstable keys restart the effect on every recomposition. Passing a freshly allocated object or lambda as a key means
remember'schangedcheck is true every time, so the runtime forgets and remembers a new impl each recomposition, cancelling and relaunching your coroutine constantly. Keys must be stable values with meaningful equality. LaunchedEffect\(Unit\)runs once and never restarts. The keyless overload exists only as a deprecated error shadow that fails to compile, soUnitis the idiom for run once. Because it never sees a new key, the block also never sees updated values except through state it reads, so watch for captured values going stale.- Prefer a coroutine free effect for synchronous reactions. If a block only reacts to a key without suspending,
DisposableEffector aRememberedEffectstyle effect skips the coroutine entirely. ReserveLaunchedEffectfor blocks that genuinely suspend. - Do not launch into
rememberCoroutineScopefrom composition. Launching as a side effect of composition into that scope produces work that is not tied to any key and is easy to duplicate on recomposition. UseLaunchedEffectfor composition driven coroutines andrememberCoroutineScopefor event driven ones. - Effects run on the apply thread, after changes.
onRemembered,onForgotten, and side effects all fire after the tree has been updated for the frame, not during your composable's execution, which is why effects see a consistent, committed tree.
Conclusion
In this article, you've explored how Compose effect handlers work beneath their familiar surface. LaunchedEffect, DisposableEffect, and rememberCoroutineScope are each a remember that stores a RememberObserver, and the "restart on key change" behavior is really remember producing a new object while the runtime forgets the old one. LaunchedEffectImpl launches a coroutine in onRemembered and cancels it in onForgotten, with its Job parented to the recomposer and the frame clock riding in on the same context. DisposableEffect runs setup and teardown with no coroutine, SideEffect is recorded rather than remembered and runs after every apply, and the RememberEventDispatcher fires forgets in reverse, remembers forward, then side effects, with an abandon path for compositions that never commit.
Understanding these internals helps you choose the right tool and avoid subtle waste. You can see why an unstable key relaunches a coroutine every frame, why LaunchedEffect(Unit) never restarts, and why a synchronous effect wrapped in LaunchedEffect pays for a coroutine it never uses. When the block does not suspend, a coroutine free effect such as DisposableEffect or the compose-effects library's RememberedEffect gives you the same key change semantics without the scope, the Job, or the launch.
Whether you are collecting a flow, registering a listener, syncing a value to a view, or launching from a click, the same small machinery is underneath all of it. The effect handlers are thin functions over remember and RememberObserver, and the composition lifecycle does the rest. Once you can see the coroutine hiding in your LaunchedEffect, you can decide, deliberately, when you actually want it.
As always, happy coding!
— Jaewoong (skydoves)

