Skip to main content

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

Choosing the wrong Compose side effect API leads to orphaned work, missed resource cleanup, or unintended double registrations. Understanding the precise semantics of 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.
// SideEffect: runs after EVERY successful recomposition; no key, no cleanup.
// Use only to push Compose state into non-Compose systems.
SideEffect { analyticsTracker.setScreen("Home") }

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.
// LaunchedEffect: launches a suspending coroutine; cancels and restarts on key change;
// cancelled automatically when composable leaves.
// Use for async work triggered by entering composition or state changes.
LaunchedEffect(userId) { viewModel.loadUser(userId) }

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.
// DisposableEffect: for non-suspend side effects that need cleanup.
// onDispose runs when the key changes OR composable leaves. Always provide cleanup.
DisposableEffect(lifecycle) {
    val observer = LifecycleEventObserver { _, event -> viewModel.onLifecycle(event) }
    lifecycle.addObserver(observer)
    onDispose { lifecycle.removeObserver(observer) }  // always required
}

Comparison table

APITriggerCleanupPrimary use case
SideEffectAfter every successful recompositionNonePush state to non-Compose systems
LaunchedEffectComposition entry; key change cancels and restartsCoroutine cancelled on key change or exitAsync work on composition entry or state change
DisposableEffectComposition entry; key change reruns setup + cleanuponDispose on key change or exitListener/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.
// ❌ WRONG: using LaunchedEffect for a non-suspend listener registration
// (no cleanup possible — the listener leaks when composable leaves)
LaunchedEffect(Unit) {
    SomeSdk.addListener { event -> handle(event) }
    // Never removed
}

// ✅ Use DisposableEffect instead for any registration that needs cleanup
DisposableEffect(Unit) {
    SomeSdk.addListener { event -> handle(event) }
    onDispose { SomeSdk.removeListener() }
}

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.
// ❌ CHURN: new filtered List and new onClick lambdas created on every recomposition
@Composable
fun FeedScreen(posts: List<Post>, onLike: (Post) -> Unit) {
    val activePosts = posts.filter { it.isActive }  // new List every time
    LazyColumn {
        items(activePosts) { post ->
            PostCard(post, onClick = { onLike(post) })  // new lambda per item per recompose
        }
    }
}

// ✅ CORRECT: cache expensive computations; pass stable lambdas from ViewModel
@Composable
fun FeedScreen(posts: List<Post>, onLike: (Post) -> Unit) {
    val activePosts = remember(posts) { posts.filter { it.isActive } }  // recomputed only when posts changes
    LazyColumn {
        items(activePosts, key = { it.id }) { post ->   // stable key avoids full rebind
            PostCard(post, onClick = { onLike(post) })
        }
    }
}
// Move filtering to ViewModel so composable receives already-filtered list
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.
// ❌ WRONG: Toast shown on every recomposition (including trivial state changes)
@Composable
fun WelcomeScreen() {
    Toast.makeText(LocalContext.current, "Welcome!", Toast.LENGTH_SHORT).show()
}

// ✅ CORRECT: wrap in LaunchedEffect(Unit) — runs once on entry;
//             use applicationContext for Toast to avoid capturing the Activity context
@Composable
fun WelcomeScreen() {
    val context = LocalContext.current.applicationContext  // applicationContext — rotation-safe
    LaunchedEffect(Unit) {
        Toast.makeText(context, "Welcome!", Toast.LENGTH_SHORT).show()
    }
}
LocalContext.current inside a composable body may return an Activity instance. Capturing it in a Toast, analytics call, or any side effect that runs outside the composition — without using applicationContext — risks retaining the Activity after a rotation.

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.
// ❌ MEMORY WASTE: replay buffer retains the last emission of a large search result page
class SearchViewModel : ViewModel() {
    private val _results = MutableSharedFlow<SearchResultPage>(replay = 1)
    // SearchResultPage contains a List<Product> with thumbnails — potentially MBs
    // Even after the user navigates away, the last page stays pinned in the replay cache
    val results: SharedFlow<SearchResultPage> = _results
}

// ✅ CORRECT option A: use StateFlow — single cached value, clearly intentional
class SearchViewModel : ViewModel() {
    private val _results = MutableStateFlow<SearchResultPage?>(null)
    val results: StateFlow<SearchResultPage?> = _results.asStateFlow()

    fun clearResults() { _results.value = null }  // explicitly release on navigate away
}

// ✅ CORRECT option B: replay = 0 for event streams where no cache is needed
class SearchViewModel : ViewModel() {
    private val _events = MutableSharedFlow<SearchEvent>(replay = 0, extraBufferCapacity = 1)
    val events: SharedFlow<SearchEvent> = _events
}

// ✅ CORRECT option C: if large payload is needed, emit only IDs and load on demand
class SearchViewModel : ViewModel() {
    private val _resultIds = MutableStateFlow<List<String>>(emptyList())
    val resultIds: StateFlow<List<String>> = _resultIds.asStateFlow()
    // UI fetches full objects from Room cache by ID — no large objects pinned in Flow
}

Replay buffer sizing guide

Use caseRecommended typeReplay
UI state (single source of truth)StateFlow1 (built-in)
One-shot events (snackbar, navigation)SharedFlow0
Event history needed for late subscribersSharedFlowN — but only with small payloads
Large paginated resultsStateFlow<PagingData> or StateFlow<List<Id>>1, clear on exit

Checklist

  • remember(key) correctly parameterized to invalidate when meaningful input changes
  • All resources opened in composables (DB, streams, players, SDK clients) use DisposableEffect with onDispose cleanup
  • LocalContext.current.applicationContext used for long-lived remembered objects and Toast (not Activity context)
  • Lambdas passed to long-lived objects use rememberUpdatedState; paired with DisposableEffect for cleanup
  • No composable lambdas or composition-scoped objects stored in ViewModels
  • No CoroutineScope() created bare in a composable — use rememberCoroutineScope()
  • LaunchedEffect keys correctly represent when the effect should restart
  • Every DisposableEffect has a non-empty onDispose block
  • No MutableState or naked CoroutineScope in application-scoped singletons
  • RememberObserver implementations launch work in onRemembered, not init {}
  • SharedFlow replay buffer sized to minimum needed; large-payload flows use StateFlow or ID-only emission
  • Expensive in-composable computations wrapped in remember(key) or moved to ViewModel
  • LazyColumn/LazyRow items use stable key = { item.id }
  • Toast, analytics, and other side-effects placed in LaunchedEffect with applicationContext
  • LeakCanary 2.14 in debug builds with DetectLeaksAfterTestSuccess rule in UI tests
  • adb shell dumpsys meminfo Activity count checked after navigation — drops to 1 after Back
  • Heap dump captured via adb shell am dumpheap or Memory Profiler after repeated navigation
  • ApplicationExitInfo queried at startup to detect REASON_LOW_MEMORY exits in production

Build docs developers (and LLMs) love