Jetpack Compose doesn’t “leak by default.” Most Compose leaks are plain old Kotlin reference leaks where something long-lived (a ViewModel, singleton, registry, static object, app scope coroutine) ends up holding a reference to something UI-scoped (an Activity Context, a composable lambda, a CoroutineScope, a remembered object). plain old Kotlin reference leaks ViewModel object Activity Context CoroutineScope If you internalize one idea, make it this: Leaks happen when composition-scoped references escape into longer-lived holders. Leaks happen when composition-scoped references escape into longer-lived holders. Leaks happen when composition-scoped references escape into longer-lived holders. 0) The mental model you debug with Composition = runtime tree of nodes backing your UI. remember = stores an object as long as that composable instance stays in the composition. Leaving composition = screen removed / branch removed / ComposeView disposed → Compose runs disposals and cancels effect coroutines. Leak = something outside the composition still references something inside it → GC can’t collect. Composition = runtime tree of nodes backing your UI. Composition remember = stores an object as long as that composable instance stays in the composition. remember as long as that composable instance stays in the composition Leaving composition = screen removed / branch removed / ComposeView disposed → Compose runs disposals and cancels effect coroutines. Leaving composition ComposeView Leak = something outside the composition still references something inside it → GC can’t collect. Leak 1) Coroutine scope myths: what leaks vs what cancels correctly Not a leak (usually): LaunchedEffect loop LaunchedEffect This cancels when the composable leaves composition. @Composable fun PollWhileVisibleEffect() { LaunchedEffect(Unit) { while (true) { delay(1_000) // do polling work } } } @Composable fun PollWhileVisibleEffect() { LaunchedEffect(Unit) { while (true) { delay(1_000) // do polling work } } } Not a leak (usually): rememberCoroutineScope() rememberCoroutineScope() The scope is cancelled when the composable leaves composition. @Composable fun ShortLivedWorkButton() { val scope = rememberCoroutineScope() Button(onClick = { scope.launch { delay(300) // short-lived work } }) { Text("Run work") } } @Composable fun ShortLivedWorkButton() { val scope = rememberCoroutineScope() Button(onClick = { scope.launch { delay(300) // short-lived work } }) { Text("Run work") } } Real leak: GlobalScope / app-wide scope that outlives UI GlobalScope This can keep references alive far past the screen’s lifecycle. @Composable fun LeakyGlobalScopeExample() { val context = LocalContext.current Button(onClick = { // ❌ GlobalScope outlives the UI; captures 'context' (often Activity) GlobalScope.launch(Dispatchers.Main) { while (true) { delay(1_000) Toast.makeText(context, "Still running", Toast.LENGTH_SHORT).show() } } }) { Text("Start global job") } } @Composable fun LeakyGlobalScopeExample() { val context = LocalContext.current Button(onClick = { // ❌ GlobalScope outlives the UI; captures 'context' (often Activity) GlobalScope.launch(Dispatchers.Main) { while (true) { delay(1_000) Toast.makeText(context, "Still running", Toast.LENGTH_SHORT).show() } } }) { Text("Start global job") } } Fixed: tie work to composition OR ViewModel scope intentionally If the work is UI-only, keep it in UI (LaunchedEffect). If it’s app logic, run it in viewModelScope (and don’t capture UI stuff). LaunchedEffect viewModelScope class PollingViewModel : ViewModel() { private var pollingJob: Job? = null fun startPolling() { if (pollingJob != null) return pollingJob = viewModelScope.launch { while (isActive) { delay(1_000) // business polling work (no Context!) } } } fun stopPolling() { pollingJob?.cancel() pollingJob = null } } @Composable fun ViewModelScopedPollingScreen(viewModel: PollingViewModel) { Column { Button(onClick = viewModel::startPolling) { Text("Start polling") } Button(onClick = viewModel::stopPolling) { Text("Stop polling") } } } class PollingViewModel : ViewModel() { private var pollingJob: Job? = null fun startPolling() { if (pollingJob != null) return pollingJob = viewModelScope.launch { while (isActive) { delay(1_000) // business polling work (no Context!) } } } fun stopPolling() { pollingJob?.cancel() pollingJob = null } } @Composable fun ViewModelScopedPollingScreen(viewModel: PollingViewModel) { Column { Button(onClick = viewModel::startPolling) { Text("Start polling") } Button(onClick = viewModel::stopPolling) { Text("Stop polling") } } } 2) Leak Pattern: Singleton/static holder captures composition Leaky code object LeakyAppSingleton { // ❌ Never store composable lambdas / UI callbacks globally var lastScreenContent: (@Composable () -> Unit)? = null } @Composable fun LeakySingletonProviderScreen() { val content: @Composable () -> Unit = { Text("This can capture composition state") } LeakyAppSingleton.lastScreenContent = content // ❌ content() } object LeakyAppSingleton { // ❌ Never store composable lambdas / UI callbacks globally var lastScreenContent: (@Composable () -> Unit)? = null } @Composable fun LeakySingletonProviderScreen() { val content: @Composable () -> Unit = { Text("This can capture composition state") } LeakyAppSingleton.lastScreenContent = content // ❌ content() } Fixed: store data, not UI If you need global coordination, use shared state (Flow) or interfaces with explicit unregister and no UI capture. 3) Leak Pattern: remember {} lambda captures + callback registered “forever” remember {} Leaky code class MyViewModelWithCallbackRegistry : ViewModel() { private val callbacks = mutableSetOf<(String) -> Unit>() fun registerOnMessageCallback(callback: (String) -> Unit) { callbacks += callback } fun unregisterOnMessageCallback(callback: (String) -> Unit) { callbacks -= callback } fun emitMessage(message: String) { callbacks.forEach { it(message) } } } @Composable fun LeakyCallbackRegistrationScreen( viewModel: MyViewModelWithCallbackRegistry ) { val context = LocalContext.current // Leaks if this callback is stored in a longer-lived owner (ViewModel) and never unregistered. val onMessageCallback: (String) -> Unit = remember { { msg -> Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() } } LaunchedEffect(Unit) { viewModel.registerOnMessageCallback(onMessageCallback) // ❌ no unregister } Button(onClick = { viewModel.emitMessage("Hello from ViewModel") }) { Text("Emit message") } } class MyViewModelWithCallbackRegistry : ViewModel() { private val callbacks = mutableSetOf<(String) -> Unit>() fun registerOnMessageCallback(callback: (String) -> Unit) { callbacks += callback } fun unregisterOnMessageCallback(callback: (String) -> Unit) { callbacks -= callback } fun emitMessage(message: String) { callbacks.forEach { it(message) } } } @Composable fun LeakyCallbackRegistrationScreen( viewModel: MyViewModelWithCallbackRegistry ) { val context = LocalContext.current // Leaks if this callback is stored in a longer-lived owner (ViewModel) and never unregistered. val onMessageCallback: (String) -> Unit = remember { { msg -> Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() } } LaunchedEffect(Unit) { viewModel.registerOnMessageCallback(onMessageCallback) // ❌ no unregister } Button(onClick = { viewModel.emitMessage("Hello from ViewModel") }) { Text("Emit message") } } Why it leaks (the reference chain) ViewModel → callbacks set → lambda → captured context (Activity) → entire UI graph ViewModel → callbacks set → lambda → captured context (Activity) → entire UI graph Fixed code (unregister + avoid stale context) @Composable fun FixedCallbackRegistrationScreen( viewModel: MyViewModelWithCallbackRegistry ) { val context = LocalContext.current // If the Activity changes (configuration change), keep using the latest context // without re-registering the callback unnecessarily. val latestContext = rememberUpdatedState(context) DisposableEffect(viewModel) { val onMessageCallback: (String) -> Unit = { msg -> Toast.makeText(latestContext.value, msg, Toast.LENGTH_SHORT).show() } viewModel.registerOnMessageCallback(onMessageCallback) onDispose { viewModel.unregisterOnMessageCallback(onMessageCallback) } } Button(onClick = { viewModel.emitMessage("Hello from ViewModel") }) { Text("Emit message") } } @Composable fun FixedCallbackRegistrationScreen( viewModel: MyViewModelWithCallbackRegistry ) { val context = LocalContext.current // If the Activity changes (configuration change), keep using the latest context // without re-registering the callback unnecessarily. val latestContext = rememberUpdatedState(context) DisposableEffect(viewModel) { val onMessageCallback: (String) -> Unit = { msg -> Toast.makeText(latestContext.value, msg, Toast.LENGTH_SHORT).show() } viewModel.registerOnMessageCallback(onMessageCallback) onDispose { viewModel.unregisterOnMessageCallback(onMessageCallback) } } Button(onClick = { viewModel.emitMessage("Hello from ViewModel") }) { Text("Emit message") } } 4) Leak Pattern: Storing composable lambdas (or composition objects) in a ViewModel Leaky code class LeakyComposableStorageViewModel : ViewModel() { // ❌ Storing composable lambdas is a hard "don't" private var storedComposable: (@Composable () -> Unit)? = null fun storeComposable(content: @Composable () -> Unit) { storedComposable = content } fun renderStoredComposable() { // Imagine some trigger calls it later... // (Even having this reference is enough to retain composition state.) } } @Composable fun LeakyComposableStoredInViewModelScreen( viewModel: LeakyComposableStorageViewModel ) { viewModel.storeComposable { Text("This composable can capture composition state and context") } Text("Screen content") } class LeakyComposableStorageViewModel : ViewModel() { // ❌ Storing composable lambdas is a hard "don't" private var storedComposable: (@Composable () -> Unit)? = null fun storeComposable(content: @Composable () -> Unit) { storedComposable = content } fun renderStoredComposable() { // Imagine some trigger calls it later... // (Even having this reference is enough to retain composition state.) } } @Composable fun LeakyComposableStoredInViewModelScreen( viewModel: LeakyComposableStorageViewModel ) { viewModel.storeComposable { Text("This composable can capture composition state and context") } Text("Screen content") } Fixed code: store state/events, not UI data class FixedScreenUiState( val title: String = "", val isLoading: Boolean = false ) sealed interface FixedScreenUiEvent { data class ShowToast(val message: String) : FixedScreenUiEvent data class Navigate(val route: String) : FixedScreenUiEvent } class FixedStateDrivenViewModel : ViewModel() { private val _uiState = MutableStateFlow(FixedScreenUiState()) val uiState: StateFlow<FixedScreenUiState> = _uiState.asStateFlow() private val _events = MutableSharedFlow<FixedScreenUiEvent>(extraBufferCapacity = 64) val events: SharedFlow<FixedScreenUiEvent> = _events.asSharedFlow() fun onTitleChanged(newTitle: String) { _uiState.value = _uiState.value.copy(title = newTitle) } fun onSaveClicked() { _events.tryEmit(FixedScreenUiEvent.ShowToast("Saved")) } } @Composable fun FixedStateDrivenScreen(viewModel: FixedStateDrivenViewModel) { val state by viewModel.uiState.collectAsState() // or collectAsStateWithLifecycle() // Handle one-off events in UI layer (no UI references stored in VM) LaunchedEffect(viewModel) { viewModel.events.collect { event -> when (event) { is FixedScreenUiEvent.ShowToast -> { // UI decides how to show it // (Use LocalContext here; do NOT pass context into ViewModel) } is FixedScreenUiEvent.Navigate -> { // navController.navigate(event.route) } } } } Column { Text("Title: ${state.title}") Button(onClick = viewModel::onSaveClicked) { Text("Save") } } } data class FixedScreenUiState( val title: String = "", val isLoading: Boolean = false ) sealed interface FixedScreenUiEvent { data class ShowToast(val message: String) : FixedScreenUiEvent data class Navigate(val route: String) : FixedScreenUiEvent } class FixedStateDrivenViewModel : ViewModel() { private val _uiState = MutableStateFlow(FixedScreenUiState()) val uiState: StateFlow<FixedScreenUiState> = _uiState.asStateFlow() private val _events = MutableSharedFlow<FixedScreenUiEvent>(extraBufferCapacity = 64) val events: SharedFlow<FixedScreenUiEvent> = _events.asSharedFlow() fun onTitleChanged(newTitle: String) { _uiState.value = _uiState.value.copy(title = newTitle) } fun onSaveClicked() { _events.tryEmit(FixedScreenUiEvent.ShowToast("Saved")) } } @Composable fun FixedStateDrivenScreen(viewModel: FixedStateDrivenViewModel) { val state by viewModel.uiState.collectAsState() // or collectAsStateWithLifecycle() // Handle one-off events in UI layer (no UI references stored in VM) LaunchedEffect(viewModel) { viewModel.events.collect { event -> when (event) { is FixedScreenUiEvent.ShowToast -> { // UI decides how to show it // (Use LocalContext here; do NOT pass context into ViewModel) } is FixedScreenUiEvent.Navigate -> { // navController.navigate(event.route) } } } } Column { Text("Title: ${state.title}") Button(onClick = viewModel::onSaveClicked) { Text("Save") } } } 5) Leak Pattern: remember without keys (stale resource retention) remember Leaky code class ExpensiveResource(private val id: String) { fun cleanup() { /* release */ } } @Composable fun LeakyRememberKeyExample(itemId: String) { // ❌ If itemId changes, this still holds the first ExpensiveResource forever (for this composable instance) val resource = remember { ExpensiveResource(itemId) } Text("Using resource for $itemId -> $resource") } class ExpensiveResource(private val id: String) { fun cleanup() { /* release */ } } @Composable fun LeakyRememberKeyExample(itemId: String) { // ❌ If itemId changes, this still holds the first ExpensiveResource forever (for this composable instance) val resource = remember { ExpensiveResource(itemId) } Text("Using resource for $itemId -> $resource") } Fixed code: key remember + cleanup remember @Composable fun FixedRememberKeyExample(itemId: String) { val resource = remember(itemId) { ExpensiveResource(itemId) } DisposableEffect(itemId) { onDispose { resource.cleanup() } } Text("Using resource for $itemId -> $resource") } @Composable fun FixedRememberKeyExample(itemId: String) { val resource = remember(itemId) { ExpensiveResource(itemId) } DisposableEffect(itemId) { onDispose { resource.cleanup() } } Text("Using resource for $itemId -> $resource") } 6) Migration sleeper leak: ComposeView in Fragments without disposal strategy ComposeView If you’re hosting Compose inside a Fragment via ComposeView, you must ensure the composition is disposed with the Fragment’s view lifecycle, not the Fragment instance. ComposeView view lifecycle class MyComposeHostFragment : Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { return ComposeView(requireContext()).apply { setViewCompositionStrategy( ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed ) setContent { Text("Compose hosted in Fragment") } } } } class MyComposeHostFragment : Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { return ComposeView(requireContext()).apply { setViewCompositionStrategy( ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed ) setContent { Text("Compose hosted in Fragment") } } } } 7) Debugging Compose leaks: a minimal, repeatable flow Memory Profiler (heap dump approach) Navigate to LeakyScreenA. Navigate away so it’s removed (pop back stack if needed). Force GC, then take a heap dump. Search for: your Activity name ComposeView Recomposer / Composition / CompositionImpl Inspect the reference chain: Look for ViewModel, singleton, callback registry, static field, global coroutine jobs. Navigate to LeakyScreenA. LeakyScreenA Navigate away so it’s removed (pop back stack if needed). Force GC, then take a heap dump. Search for: your Activity name ComposeView Recomposer / Composition / CompositionImpl your Activity name ComposeView Recomposer / Composition / CompositionImpl your Activity name Activity ComposeView ComposeView Recomposer / Composition / CompositionImpl Recomposer Composition CompositionImpl Inspect the reference chain: Look for ViewModel, singleton, callback registry, static field, global coroutine jobs. reference chain Look for ViewModel, singleton, callback registry, static field, global coroutine jobs. Look for ViewModel, singleton, callback registry, static field, global coroutine jobs. ViewModel LeakCanary (what to watch for) Retained Activity or Fragment with a chain through a callback/lambda. Retained ComposeView or composition classes held by a static field. Retained Activity or Fragment with a chain through a callback/lambda. Activity Fragment Retained ComposeView or composition classes held by a static field. ComposeView 8) Rules that prevent 95% of Compose leaks **If you register it, you must unregister it \ Use DisposableEffect(owner). **Never store composable lambdas or UI objects in ViewModels/singletons \ Store state (StateFlow) and events (SharedFlow) instead. Avoid GlobalScopeand app-wide scopes for UI workUseLaunchedEffect or viewModelScope depending on ownership. Key your rememberIf the object depends onX, use remember(X). Be careful with ContextDon’t capture anActivity context into long-lived callbacks. Use rememberUpdatedState or redesign so the UI handles UI. **If you register it, you must unregister it \ Use DisposableEffect(owner). DisposableEffect(owner) **Never store composable lambdas or UI objects in ViewModels/singletons \ Store state (StateFlow) and events (SharedFlow) instead. state StateFlow events SharedFlow Avoid GlobalScopeand app-wide scopes for UI workUseLaunchedEffect or viewModelScope depending on ownership. Avoid GlobalScope LaunchedEffect viewModelScope Key your rememberIf the object depends onX, use remember(X). Key your remember X remember(X) Be careful with ContextDon’t capture anActivity context into long-lived callbacks. Use rememberUpdatedState or redesign so the UI handles UI. Be careful with Context Activity rememberUpdatedState Final takeaway Compose is not the villain. Your leaks are almost always one of these: Long-lived owner (VM/singleton) holds a UI lambda Registered callback not unregistered Global coroutine captures UI Unkeyed remember retains stale resources ComposeView composition outlives Fragment view Long-lived owner (VM/singleton) holds a UI lambda Long-lived owner (VM/singleton) holds a UI lambda Registered callback not unregistered Registered callback not unregistered Global coroutine captures UI Global coroutine captures UI Unkeyed remember retains stale resources Unkeyed remember retains stale resources ComposeView composition outlives Fragment view ComposeView composition outlives Fragment view