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 /
ComposeViewdisposed → 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)
- Navigate to
LeakyScreenA. - Navigate away so it’s removed (pop back stack if needed).
- Force GC, then take a heap dump.
- Search for:
- your
Activityname ComposeViewRecomposer/Composition/CompositionImpl
- your
- Inspect the reference chain:
- Look for
ViewModel, singleton, callback registry, static field, global coroutine jobs.
- Look for
LeakCanary (what to watch for)
- Retained
ActivityorFragmentwith a chain through a callback/lambda. - Retained
ComposeViewor composition classes held by a static field.
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 work
UseLaunchedEffectorviewModelScopedepending on ownership. - Key your
remember
If the object depends onX, useremember(X). - Be careful with
Context
Don’t capture anActivitycontext into long-lived callbacks. UserememberUpdatedStateor 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