Jetpack Compose memory leaks are usually reference leaks. Learn the top leak patterns, why they happen, and how to fix them.Jetpack Compose memory leaks are usually reference leaks. Learn the top leak patterns, why they happen, and how to fix them.

Jetpack Compose Memory Leaks: A Reference-Graph Deep Dive

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

If you internalize one idea, make it this:

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.

1) Coroutine scope myths: what leaks vs what cancels correctly

Not a leak (usually): LaunchedEffect loop

This cancels when the composable leaves composition.

@Composable fun PollWhileVisibleEffect() { LaunchedEffect(Unit) { while (true) { delay(1_000) // do polling work } } }

Not a leak (usually): 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") } }

Real leak: GlobalScope / app-wide scope that outlives UI

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") } }

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

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() }

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”

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") } }

Why it leaks (the reference chain)

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") } }

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") }

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") } } }

5) Leak Pattern: remember without keys (stale resource retention)

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") }

Fixed code: key remember + cleanup

@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

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.

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)

  1. Navigate to LeakyScreenA.
  2. Navigate away so it’s removed (pop back stack if needed).
  3. Force GC, then take a heap dump.
  4. Search for:
  • your Activity name
  • ComposeView
  • Recomposer / Composition / CompositionImpl
  1. Inspect the reference chain:
  • Look for ViewModel, singleton, callback registry, static field, global coroutine jobs.

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.

8) Rules that prevent 95% of Compose leaks

  1. **If you register it, you must unregister it \ Use DisposableEffect(owner).
  2. **Never store composable lambdas or UI objects in ViewModels/singletons \ Store *state* (StateFlow) and events (SharedFlow) instead.
  3. Avoid GlobalScope and app-wide scopes for UI work \n Use LaunchedEffect or viewModelScope depending on ownership.
  4. Key your remember \n If the object depends on X, use remember(X).
  5. Be careful with Context \n Don’t capture an Activity context into long-lived callbacks. Use rememberUpdatedState or redesign so the UI handles UI.

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

\

Market Opportunity
DeepBook Logo
DeepBook Price(DEEP)
$0.052104
$0.052104$0.052104
-7.51%
USD
DeepBook (DEEP) Live Price Chart
Disclaimer: The articles reposted on this site are sourced from public platforms and are provided for informational purposes only. They do not necessarily reflect the views of MEXC. All rights remain with the original authors. If you believe any content infringes on third-party rights, please contact service@support.mexc.com for removal. MEXC makes no guarantees regarding the accuracy, completeness, or timeliness of the content and is not responsible for any actions taken based on the information provided. The content does not constitute financial, legal, or other professional advice, nor should it be considered a recommendation or endorsement by MEXC.