Jetpack Compose Memory Leaks: A Reference-Graph Deep Dive

Written by mohansankaran | Published 2026/01/07
Tech Story Tags: android | jetpack-compose | memory-leaks | kotlin | mobile-development | performance | debugging | programming

TLDRJetpack Compose memory leaks are usually reference leaks. Learn the top leak patterns, why they happen, and how to fix them.via the TL;DR App

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:

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.

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
  5. 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 GlobalScopeand app-wide scopes for UI work
    UseLaunchedEffect or viewModelScope depending on ownership.
  4. Key your remember
    If the object depends onX, use remember(X).
  5. Be careful with Context
    Don’t capture anActivity 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


Written by mohansankaran | Mohan leads Android Engineering for the Business Experience in the PayPal.
Published by HackerNoon on 2026/01/07