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.

Jetpack Compose introduces memory leak patterns that don’t exist in the View system. The composition model — where composables recompose frequently and capture variables from their surrounding scope — creates new ways for long-lived objects like ViewModels and singletons to hold references to composition-scoped data that should have been released.

remember without a key — stale object retention

remember {} caches a value across recompositions. Without a key, the same object instance is returned even when a meaningful input changes. If that object holds open resources — threads, database connections, subscriptions — those resources remain tied to stale inputs and are never released.
// ❌ LEAK: ExpensiveResource created for first itemId is never released or updated when itemId changes
@Composable
fun ItemDetail(itemId: String) {
    val resource = remember { ExpensiveResource(itemId) }  // never re-created for new itemId
}

// ✅ CORRECT: key remember to inputs that should invalidate the object
@Composable
fun ItemDetail(itemId: String) {
    val resource = remember(itemId) { ExpensiveResource(itemId) }
    DisposableEffect(itemId) {
        onDispose { resource.close() }  // released when itemId changes OR composable leaves
    }
}
Always pass every input that logically invalidates the cached object as a key to remember. If the object holds resources, pair it with DisposableEffect using the same key to guarantee cleanup.

Resources created in composition without DisposableEffect

Composable function bodies run on every recomposition. Resources opened directly in the body — database instances, media players, SDK clients — are created repeatedly and never closed when the composable leaves the tree.
// ❌ LEAK: Room database created on every recomposition; never closed
@Composable
fun DataScreen() {
    val context = LocalContext.current
    val db = Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build()
    // db.close() never called; new instance opened every recompose
}

// ✅ CORRECT: DisposableEffect lifecycle matches the composable's presence in the Composition
@Composable
fun DataScreen() {
    val context = LocalContext.current.applicationContext
    DisposableEffect(Unit) {
        val db = Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build()
        onDispose { db.close() }  // closed when composable leaves the tree
    }
}

Activity context captured in remember — configuration change leak

LocalContext.current in a composable may return an Activity instance. Capturing that in remember {} without a key means the same Activity reference is used across all subsequent recompositions. After a configuration change (rotation), a new Activity is created, but the remember cache still holds the old one — preventing it from being garbage collected.
// ❌ LEAK: remember captures an Activity context; after rotation old Activity is retained
@Composable
fun MyScreen() {
    val context = LocalContext.current  // may be Activity
    val helper = remember { SomeSdkHelper(context) }  // keeps old Activity after rotation
}

// ✅ CORRECT: use applicationContext for long-lived remembered objects
@Composable
fun MyScreen() {
    val appContext = LocalContext.current.applicationContext
    val helper = remember { SomeSdkHelper(appContext) }  // safe across rotations
}

Lambdas capturing composition-scoped references in long-lived objects

Lambdas defined in composables capture all variables they reference from the enclosing scope. Passing such a lambda to a singleton or SDK stores composition-scoped objects — state snapshots, binding contexts — in a location that outlives the composable. The captured references can never be collected as long as the long-lived object is alive.
// ❌ LEAK: lambda captures composition state; LocationSdk (singleton) holds it forever
@Composable
fun TrackingScreen(viewModel: TrackingViewModel) {
    val currentState by viewModel.state.collectAsState()

    LaunchedEffect(Unit) {
        LocationSdk.setCallback { location ->
            viewModel.onLocation(location, currentState)  // currentState captured by closure — stale
        }
        // No cleanup → LocationSdk holds the lambda forever after composable leaves
    }
}

// ✅ CORRECT: rememberUpdatedState for stable latest-value access; DisposableEffect for cleanup
@Composable
fun TrackingScreen(viewModel: TrackingViewModel) {
    val latestState by rememberUpdatedState(viewModel.state.collectAsState().value)

    DisposableEffect(Unit) {
        val callback = LocationCallback { loc -> viewModel.onLocation(loc, latestState) }
        LocationSdk.setCallback(callback)
        onDispose { LocationSdk.clearCallback() }  // cleanup when composable leaves
    }
}
Use rememberUpdatedState whenever a long-lived callback needs access to the most recent value of a composable parameter or state. The callback always reads the current value without re-registering, and DisposableEffect ensures cleanup on exit.

Composable lambda stored in ViewModel

A composable lambda captures the entire composition context — the slot table and snapshot scope. Storing it in a ViewModel prevents the composition, and everything it references, from being garbage collected. The ViewModel survives configuration changes, so the leak persists across rotations.
Never store composable lambdas in ViewModels. A @Composable function reference in a ViewModel retains the entire composition tree for as long as the ViewModel lives.
// ❌ LEAK: ViewModel holds composable lambda → entire composition tree retained
class ProfileViewModel : ViewModel() {
    var onLoaded: (@Composable () -> Unit)? = null  // NEVER store composable lambdas in VM
}

@Composable
fun ProfileScreen(vm: ProfileViewModel) {
    vm.onLoaded = { Text("Profile loaded") }  // composition scope captured in ViewModel
}

// ✅ CORRECT: expose state via StateFlow; composable observes and reacts
class ProfileViewModel : ViewModel() {
    private val _profile = MutableStateFlow<Profile?>(null)
    val profile: StateFlow<Profile?> = _profile.asStateFlow()
    fun load() { viewModelScope.launch { _profile.value = repo.fetch() } }
}

@Composable
fun ProfileScreen(vm: ProfileViewModel) {
    val profile by vm.profile.collectAsState()
    profile?.let { ProfileContent(it) }
}

Bare CoroutineScope() in a composable — orphaned jobs

A CoroutineScope() created without remember is re-created on every recomposition. The previous scope is abandoned without cancellation, and any coroutines it launched continue running — holding captured references — until they complete naturally.
// ❌ LEAK: new CoroutineScope created on every recomposition; previous jobs orphaned
@Composable
fun SearchScreen() {
    val scope = CoroutineScope(Dispatchers.IO)  // new scope, new Job, every recompose
    Button(onClick = { scope.launch { search() } }) { Text("Search") }
}

// ✅ CORRECT: rememberCoroutineScope — stable across recompositions, cancelled when composable leaves
@Composable
fun SearchScreen() {
    val scope = rememberCoroutineScope()
    Button(onClick = { scope.launch { search() } }) { Text("Search") }
}

// ✅ For work keyed to composition entry: LaunchedEffect
@Composable
fun AutoSearch(query: String) {
    LaunchedEffect(query) {          // cancels previous, restarts when query changes, cleans up on exit
        delay(300)                   // debounce
        performSearch(query)
    }
}

MutableState / scope stored in application-scoped singletons

Compose snapshot state (mutableStateOf) interacts with the composition via the snapshot system. Placing snapshot state in an application-scoped singleton creates a permanent reference from the snapshot system to the composition that wrote to it. A bare CoroutineScope in a singleton is never cancelled, leaving coroutines running for the entire process lifetime.
// ❌ LEAK: Compose snapshot state in a singleton → snapshot system retains composition reference
object GlobalUiState {
    val isLoading = mutableStateOf(false)     // prevents composition GC
    val appScope = CoroutineScope(Dispatchers.Main)  // never cancelled
}

// ✅ CORRECT: ViewModel-scoped state; application-scoped scope via DI
class AppViewModel : ViewModel() {
    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
}

@Singleton
class AppCoroutineScope @Inject constructor() : CoroutineScope by CoroutineScope(
    SupervisorJob() + Dispatchers.Default
)

RememberObserver work started in constructor

Objects implementing RememberObserver must start cancellable work in onRemembered, not in init {}. Work started in the constructor runs during the composition phase and may be orphaned if composition is abandoned or deferred before the slot table is applied.
// ❌ LEAK: coroutine started in init{} before onRemembered; orphaned if composition is abandoned
class DataPoller : RememberObserver {
    private val job = Job()
    private val scope = CoroutineScope(Dispatchers.Main + job)

    init {
        scope.launch { poll() }  // starts during composition — may be orphaned
    }

    override fun onRemembered() {}
    override fun onForgotten() { job.cancel() }
    override fun onAbandoned() { job.cancel() }
}

// ✅ CORRECT
class DataPoller : RememberObserver {
    private val job = Job()
    private val scope = CoroutineScope(Dispatchers.Main + job)

    override fun onRemembered() {
        scope.launch { poll() }  // starts only when composition is applied
    }
    override fun onForgotten() { job.cancel() }
    override fun onAbandoned() { job.cancel() }
}

LocalContext in remember without applicationContext — rotation leak

The same Activity context problem applies any time LocalContext.current is passed to a long-lived remembered object. A common instance is location service clients: passing the Activity context ties the client — and all future callbacks — to a specific Activity instance that will be destroyed on rotation.
// ❌ LEAK: Activity context cached in remember across rotations
@Composable
fun LocationScreen() {
    val context = LocalContext.current
    val fusedClient = remember { LocationServices.getFusedLocationProviderClient(context) }
    // After rotation, old Activity is retained by fusedClient
}

// ✅ CORRECT: applicationContext is rotation-safe
@Composable
fun LocationScreen() {
    val context = LocalContext.current.applicationContext
    val fusedClient = remember { LocationServices.getFusedLocationProviderClient(context) }
}

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