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.
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.

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.
Unitis 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.
Related
- remember is not a cache, and the key is the whole contract — the same keying discipline
- StateFlow drops what you needed and keeps what you didn't — why navigation is an event
- A shared ViewModel has no lifecycle, so you have to give it one — the lifetime the analytics moved to
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".