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,055
$0,055$0,055
-2,37%
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 [email protected] 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.

You May Also Like

Grayscale Registers New HYPE and BNB ETFs in Delaware

Grayscale Registers New HYPE and BNB ETFs in Delaware

The post Grayscale Registers New HYPE and BNB ETFs in Delaware appeared on BitcoinEthereumNews.com. Key Points: Grayscale registers ETFs in Delaware. Market anticipates
Share
BitcoinEthereumNews2026/01/12 06:17
Fed Decides On Interest Rates Today—Here’s What To Watch For

Fed Decides On Interest Rates Today—Here’s What To Watch For

The post Fed Decides On Interest Rates Today—Here’s What To Watch For appeared on BitcoinEthereumNews.com. Topline The Federal Reserve on Wednesday will conclude a two-day policymaking meeting and release a decision on whether to lower interest rates—following months of pressure and criticism from President Donald Trump—and potentially signal whether additional cuts are on the way. President Donald Trump has urged the central bank to “CUT INTEREST RATES, NOW, AND BIGGER” than they might plan to. Getty Images Key Facts The central bank is poised to cut interest rates by at least a quarter-point, down from the 4.25% to 4.5% range where they have been held since December to between 4% and 4.25%, as Wall Street has placed 100% odds of a rate cut, according to CME’s FedWatch, with higher odds (94%) on a quarter-point cut than a half-point (6%) reduction. Fed governors Christopher Waller and Michelle Bowman, both Trump appointees, voted in July for a quarter-point reduction to rates, and they may dissent again in favor of a large cut alongside Stephen Miran, Trump’s Council of Economic Advisers’ chair, who was sworn in at the meeting’s start on Tuesday. It’s unclear whether other policymakers, including Kansas City Fed President Jeffrey Schmid and St. Louis Fed President Alberto Musalem, will favor larger cuts or opt for no reduction. Fed Chair Jerome Powell said in his Jackson Hole, Wyoming, address last month the central bank would likely consider a looser monetary policy, noting the “shifting balance of risks” on the U.S. economy “may warrant adjusting our policy stance.” David Mericle, an economist for Goldman Sachs, wrote in a note the “key question” for the Fed’s meeting is whether policymakers signal “this is likely the first in a series of consecutive cuts” as the central bank is anticipated to “acknowledge the softening in the labor market,” though they may not “nod to an October cut.” Mericle said he…
Share
BitcoinEthereumNews2025/09/18 00:23
FCA komt in 2026 met aangepaste cryptoregels voor Britse markt

FCA komt in 2026 met aangepaste cryptoregels voor Britse markt

De Britse financiële waakhond, de FCA, komt in 2026 met nieuwe regels speciaal voor crypto bedrijven. Wat direct opvalt: de toezichthouder laat enkele klassieke financiële verplichtingen los om beter aan te sluiten op de snelle en grillige wereld van digitale activa. Tegelijkertijd wordt er extra nadruk gelegd op digitale beveiliging,... Het bericht FCA komt in 2026 met aangepaste cryptoregels voor Britse markt verscheen het eerst op Blockchain Stories.
Share
Coinstats2025/09/18 00:33