Engineering

LaunchedEffect(Unit) runs once, and that is usually the wrong once

Keyed to Unit means once per composition entry — not once per screen visit, and not once per user.

MobileByteSensei 8 min read
composearchitectureandroid

TL;DR

LaunchedEffect(Unit) { … } is read as "run this once". It runs once per entry of this call site into composition — which is a narrower and stranger lifetime than most of the things people use it for.

So an analytics event fires again when the user scrolls the screen back into a lazy list's cache. A one-time data load re-runs after a configuration change on some setups and not others. And a navigation triggered from an effect fires twice when the back stack animates.

Unit is a key, and it is almost never the key you mean. Choose one that matches the event you are actually trying to tie the effect to.

The case this post is about

A detail screen doing three one-time things:

@Composable
fun ArticleScreen(articleId: String, vm: ArticleViewModel) {
    LaunchedEffect(Unit) {
        vm.load(articleId)                       // fetch
        analytics.log("article_viewed", articleId)   // track
    }

    val state by vm.state.collectAsStateWithLifecycle()
    if (state.readComplete) {
        LaunchedEffect(Unit) { navController.navigate("next") }   // navigate
    }
    ArticleContent(state)
}

Three effects, all keyed Unit, all meaning "once". Each one turned out to mean something different.

The symptom

Analytics inflated by roughly 40%. article_viewed fired more often than articles were opened:

unique articles opened (server-side)   1,240
article_viewed events                  1,737

The fetch did not re-run when the id changed. Navigating from one article to another within the same composable reused the old content:

article/42 → article/43
  state still shows article 42; load(43) never called

And the navigation fired twice, producing a double entry on the back stack:

readComplete = true
  → navigate("next")
  → navigate("next")     back stack: [article, next, next]

Three symptoms, three different wrong keys, all spelled Unit.

Why the obvious fix failed

"Add a hasRun boolean." The reflex, and it moves the problem into state you now have to scope correctly:

var tracked by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { if (!tracked) { analytics.log(...); tracked = true } }

remember has the same lifetime as the effect, so this changes nothing. rememberSaveable makes it survive rotation and not navigation. The flag is a worse-specified version of the key.

"Use DisposableEffect instead." A different tool — it is for setup/teardown pairs, not for one-shot work — and it has the identical keying semantics. Same bug, different API.

"Move the analytics call into the ViewModel's init." This genuinely fixes the analytics case, because the ViewModel's lifetime is the screen's. It is the right answer for that one, and it does not teach the key, so the navigation bug survives.

"Guard the navigation with state.navigated." Works and spreads one-shot bookkeeping into the state model. The event-versus-state distinction is the real fix there.

The actual mechanism

LaunchedEffect(keys) { block } does three things, and the key governs all of them.

On first composition of the call site, launch block in a coroutine scoped to that call site's presence in composition.

On recomposition, compare the keys. Equal → leave the running coroutine alone. Not equal → cancel the existing coroutine and launch a new one.

On leaving composition, cancel.

So the lifetime of the effect is the lifetime of the call site in composition, and Unit means "never restart while that lifetime continues". What makes Unit misleading is how many ways that lifetime can end and begin again:

Leaving and re-entering a lazy list's cache. A composable inside a LazyColumn leaves composition when scrolled far enough away and re-enters on return. New entry, new effect — that is the 40% of extra analytics events.

Navigation. Navigating away and back is a new composition entry, so "once" happens per visit.

Configuration change, depending on whether the composition is recreated.

That "depending on" is not vagueness — it is the genuinely unhelpful part. Whether a rotation re-enters the composition varies with how the activity is configured, which Compose integration is in use, and the platform, so the same LaunchedEffect(Unit) fires once per visit on one setup and twice on another. An effect whose correctness depends on that is not wrong in a way you can test for; it is wrong in a way that reproduces on some devices.

And on recomposition with a changed key, the cancellation is the part people forget. An effect whose key changes mid-flight has its coroutine cancelled — correct for a fetch, and a silent data-loss bug if the block was writing something.

LaunchedEffect keyed by its arguments: unchanged keys leave the running coroutine alone, changed keys cancel it and launch a new one, and leaving composition cancels — with the composition-entry lifetime beginning again on navigation, on re-entry into a lazy list's cache, and on configuration change, so Unit means once per entry rather than once per screen visit or once per user

And the mirror-image trap: a key that is a new object every recomposition. The same comparison that makes Unit never restart makes an unstable key restart constantly — cancelling the coroutine mid-flight and relaunching it, every frame:

LaunchedEffect(onLoaded) { … }                    // a lambda parameter — new identity each time
LaunchedEffect(user.permissions.toList()) { … }   // a fresh List allocated on every call
LaunchedEffect(viewModel.state.value) { … }       // restarts on every state change, not just the
                                                   // one you meant

Each of these reads as "restart when this changes" and means "restart always". The symptom is not an error — it is a network call that never completes, because it is cancelled and reissued before it can return, which presents as a permanently loading screen with a server log full of requests.

The two failure modes are therefore the same mechanism read from opposite ends, and they want opposite fixes: Unit needs a narrower lifetime (a real key), an unstable key needs a stabler one (an id, a primitive, a remembered value). The question that distinguishes them is the same one in both directions — what is the precise condition under which this work should start again? — and neither Unit nor an allocation is an answer to it.

Unit is not "once ever". It is "once per composition entry" — and the number of composition entries is a UI detail, not a product concept. Any effect whose correct frequency is a product concept is keyed wrong.

The fix

One answer per effect, because they were three different questions.

The fetch keys on the thing it fetches:

LaunchedEffect(articleId) { vm.load(articleId) }

Now navigating to a different id re-runs it, and the in-flight load for the previous id is cancelled — which is correct and is the behaviour the cancellation semantics exist for.

The analytics belongs to the screen's lifetime, not the composition's:

// ViewModel — one instance per screen entry, by construction
class ArticleViewModel(private val articleId: String, …) : ViewModel() {
    init { analytics.log("article_viewed", articleId) }
}

The ViewModel survives configuration change and lazy-list re-entry, and is created once per navigation. Its lifetime is the one the product concept "a view" actually means.

And the navigation is an event, not derived state:

// ViewModel
private val _events = Channel<ArticleEvent>(capacity = 8)
val events: Flow<ArticleEvent> = _events.receiveAsFlow()

fun onReadComplete() = scope.launch { _events.send(ArticleEvent.GoNext) }
// the screen collects events once; a Channel delivers each exactly once
LaunchedEffect(Unit) {
    vm.events.collect { e -> when (e) { ArticleEvent.GoNext -> navController.navigate("next") } }
}

Note that this is LaunchedEffect(Unit), and here it is correct: the intent is "for as long as this screen is composed, collect events", which is exactly a composition-entry lifetime. The key is right when the thing you are tying to really is the composition.

Two habits worth adopting:

Make the key the thing the effect depends on. If the block reads articleId, the key is articleId. This is the same discipline as remember keys, and the compiler cannot check it — but "does the block read anything not in the keys?" catches almost every instance.

Ask what lifetime the effect's frequency belongs to:

once per composition entry   →  LaunchedEffect(Unit)       collecting, observing, subscribing
once per input value         →  LaunchedEffect(input)      fetching, syncing
once per screen entry        →  ViewModel init             analytics, one-time setup
once per user action         →  an event channel           navigation, snackbars
once ever, per user          →  persisted flag             onboarding, first-run prompts

That table is the actual content of this post. Most LaunchedEffect(Unit) bugs are a row mismatch.

How we proved it

That the analytics event fires once per visit:

@Test
fun articleViewedFiresOncePerNavigationNotPerComposition() {
    composeTestRule.setContent { ArticleScreen("42", vm) }
    composeTestRule.onRoot().performTouchInput { swipeUp() }   // scroll out of the lazy cache
    composeTestRule.onRoot().performTouchInput { swipeDown() } // and back
    composeTestRule.waitForIdle()

    assertEquals(1, analytics.countOf("article_viewed"))
}
LaunchedEffect(Unit):  FAILED — 2 events
ViewModel init:        PASSED — 1

That the fetch follows the id:

@Test
fun changingTheIdReloads() {
    var id by mutableStateOf("42")
    composeTestRule.setContent { ArticleScreen(id, vm) }
    composeTestRule.waitForIdle()
    id = "43"
    composeTestRule.waitForIdle()

    assertEquals(listOf("42", "43"), vm.loadedIds)
}
LaunchedEffect(Unit):       FAILED — loadedIds = ["42"]
LaunchedEffect(articleId):  PASSED

And that navigation happens once:

readComplete → true
  before:  back stack [article, next, next]
  after:   back stack [article, next]

Plus the production numbers after release:

                              before    after
unique articles (server)       1,240     1,238
article_viewed events          1,737     1,241

1,241 against 1,238 is the honest result — a near-match with a small residue, not a perfect one. The remainder is most likely genuine re-entries from process death and restore, which I did not chase further.

One limit: the lazy-cache re-entry in the first test is a swipe, which is a proxy for the production condition rather than the condition itself. It reproduces the double-fire reliably, which is what a regression test needs, and it is not proof that every production path was the same one.

What this generalizes to

"Once" is meaningless without naming the scope. Once per request, per session, per user, per process, per mount. Every effect, retry, cache and idempotency guard needs that noun, and bugs cluster where the code says "once" and the product means a different noun.

A key is a declaration of when to restart. Same lesson as memoisation keys: the parameter that looks like plumbing is the specification. Unit is a real choice, correct for exactly one of the rows in that table.

Cancellation is half of a restart. An effect whose key changes does not just start again — the previous run is cancelled. Correct for reads, dangerous for writes, and easy to forget when changing a key from Unit to something real.

Put an effect at the lifetime that matches its meaning. The analytics event belonged to the ViewModel, the navigation to an event channel, the fetch to the composition. Choosing the owner is the fix; choosing the key is how you express it.

Keep reading

This is part of 100 Days of Compose — the mechanisms underneath the API, measured rather than guessed. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS