Choosing the wrong Compose side effect API leads to orphaned work, missed resource cleanup, or unintended double registrations. Understanding the precise semantics ofDocumentation Index
Fetch the complete documentation index at: https://mintlify.com/AmolPardeshi99/android-performance-skills/llms.txt
Use this file to discover all available pages before exploring further.
SideEffect, LaunchedEffect, and DisposableEffect — and when each applies — prevents both memory leaks and incorrect behavior.
Side effect API selection
SideEffect
SideEffect runs after every successful recomposition. It has no key and no cleanup mechanism. Use it exclusively to push Compose state into non-Compose systems that need to stay in sync with the current composition output.
LaunchedEffect
LaunchedEffect launches a suspending coroutine tied to the composable’s lifetime. When a key changes, the previous coroutine is cancelled and a new one is started. The coroutine is cancelled automatically when the composable leaves the tree.
DisposableEffect
DisposableEffect handles non-suspending side effects that require symmetric cleanup. The onDispose block runs whenever the key changes or the composable leaves the tree. Every DisposableEffect must have a non-empty onDispose block — if no cleanup is needed, the API choice is wrong.
Comparison table
| API | Trigger | Cleanup | Primary use case |
|---|---|---|---|
SideEffect | After every successful recomposition | None | Push state to non-Compose systems |
LaunchedEffect | Composition entry; key change cancels and restarts | Coroutine cancelled on key change or exit | Async work on composition entry or state change |
DisposableEffect | Composition entry; key change reruns setup + cleanup | onDispose on key change or exit | Listener/observer registration with paired unregistration |
Using the wrong API — listener registration in LaunchedEffect
A common mistake is using LaunchedEffect to register a non-suspending listener. Because LaunchedEffect has no cleanup hook, the listener is never removed when the composable leaves.
Object allocation churn during recomposition
Objects allocated inline in a composable body — lambdas, derived collections, formatted strings — are re-created on every recomposition. In fast-recomposing screens driven by scroll or animation, this produces sustained GC pressure and jank.Stable keys in
LazyColumn and LazyRow (key = { item.id }) allow the list to match items across recompositions by identity instead of position. Without stable keys, all item compositions are rebind candidates on every list update.Recomposition-triggered side effects
Placing imperative side effects —Toast.makeText, logging calls, analytics events — directly in a composable body causes them to fire on every recomposition, including trivial state changes that have nothing to do with the intended trigger.
SharedFlow / StateFlow replay buffer holding large objects
SharedFlow(replay = N) keeps the last N emitted values in memory regardless of whether any collector is active. If those values contain large domain objects, bitmaps, or paginated result pages, they are pinned in the replay cache indefinitely. This is not flagged by LeakCanary because the reference is technically live — but the memory is functionally wasted and will not be recovered until a new emission overwrites the slot.
Replay buffer sizing guide
| Use case | Recommended type | Replay |
|---|---|---|
| UI state (single source of truth) | StateFlow | 1 (built-in) |
| One-shot events (snackbar, navigation) | SharedFlow | 0 |
| Event history needed for late subscribers | SharedFlow | N — but only with small payloads |
| Large paginated results | StateFlow<PagingData> or StateFlow<List<Id>> | 1, clear on exit |
Checklist
Jetpack Compose leaks checklist
Jetpack Compose leaks checklist
-
remember(key)correctly parameterized to invalidate when meaningful input changes - All resources opened in composables (DB, streams, players, SDK clients) use
DisposableEffectwithonDisposecleanup -
LocalContext.current.applicationContextused for long-lived remembered objects and Toast (not Activity context) - Lambdas passed to long-lived objects use
rememberUpdatedState; paired withDisposableEffectfor cleanup - No composable lambdas or composition-scoped objects stored in ViewModels
- No
CoroutineScope()created bare in a composable — userememberCoroutineScope() -
LaunchedEffectkeys correctly represent when the effect should restart - Every
DisposableEffecthas a non-emptyonDisposeblock - No
MutableStateor nakedCoroutineScopein application-scoped singletons -
RememberObserverimplementations launch work inonRemembered, notinit {} -
SharedFlowreplay buffer sized to minimum needed; large-payload flows useStateFlowor ID-only emission - Expensive in-composable computations wrapped in
remember(key)or moved to ViewModel -
LazyColumn/LazyRowitems use stablekey = { item.id } - Toast, analytics, and other side-effects placed in
LaunchedEffectwithapplicationContext
Detection and tooling checklist
Detection and tooling checklist
- LeakCanary 2.14 in debug builds with
DetectLeaksAfterTestSuccessrule in UI tests -
adb shell dumpsys meminfoActivity count checked after navigation — drops to 1 after Back - Heap dump captured via
adb shell am dumpheapor Memory Profiler after repeated navigation -
ApplicationExitInfoqueried at startup to detectREASON_LOW_MEMORYexits in production