Understanding Android Lifecycle at Scale
How lifecycle mismanagement causes memory leaks, state loss, and crashes in large Android apps, with patterns for handling lifecycle correctly across complex component hierarchies.
Context
The Android lifecycle is not complicated for a single Activity with one Fragment. It becomes a significant source of bugs when you have 50+ screens, nested navigation graphs, multi-process architectures, and dozens of background observers competing for lifecycle awareness. At scale, lifecycle mismanagement is the leading cause of memory leaks, state loss after process death, and crashes from accessing destroyed views.
Problem
Large apps accumulate lifecycle-sensitive components over years of development. Each team adds LiveData observers, lifecycle-aware coroutines, view bindings, and third-party SDK hooks. Without strict discipline, these interact in unexpected ways:
- A coroutine launched in
onStartoutlives the Fragment because the scope is tied to the wrong lifecycle - A ViewModel survives configuration change but its state references a destroyed Activity context
- Process death restores a Fragment whose arguments reference data that no longer exists
- A
repeatOnLifecycleblock in a deeply nested Fragment continues collecting from a Flow after the parent Fragment is detached
Constraints
- Must handle configuration changes without data loss
- Must survive process death and restore state correctly
- Must avoid memory leaks from lifecycle-outliving references
- Must work with both single-Activity and multi-Activity navigation patterns
- Must support Compose and View-based screens coexisting in the same navigation graph
Design
Related: How I'd Design a Mobile Configuration System at Scale.
Lifecycle Ownership Hierarchy
Understanding who owns what lifecycle is the foundation.
| Component | Lifecycle Owner | Survives Config Change | Survives Process Death |
|---|---|---|---|
| Application | Process | Yes | No |
| Activity | Activity | No (recreated) | No (recreated) |
| Fragment | Fragment | No (recreated) | No (recreated) |
| ViewModel | ViewModelStoreOwner | Yes | No |
| SavedStateHandle | SavedStateRegistry | Yes | Yes |
| Navigation backstack | NavController | Yes | Yes (saved) |
Pattern 1: Correct Coroutine Scope Selection
class FeedFragment : Fragment() {
private val viewModel: FeedViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// CORRECT: tied to view lifecycle, cancels when view is destroyed
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
renderState(state)
}
}
}
// WRONG: tied to Fragment lifecycle, can outlive the view
// lifecycleScope.launch {
// viewModel.uiState.collect { renderState(it) } // crash: view is null
// }
}
}The distinction between viewLifecycleOwner.lifecycleScope and lifecycleScope causes bugs in every large codebase. The Fragment's lifecycle and its view's lifecycle are different. The view is destroyed on back stack transactions while the Fragment instance survives.
Pattern 2: ViewModel Scoping for Shared State
// Shared ViewModel scoped to the navigation graph, not individual fragments
class CheckoutSharedViewModel @Inject constructor(
private val cartRepo: CartRepository,
private val savedState: SavedStateHandle
) : ViewModel() {
// Survives process death via SavedStateHandle
val selectedAddressId: StateFlow<String?> =
savedState.getStateFlow("address_id", null)
fun selectAddress(id: String) {
savedState["address_id"] = id
}
// Does NOT survive process death (complex object, fetch from repo)
val cartItems: StateFlow<List<CartItem>> = cartRepo.observe()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}
// In any Fragment within the checkout nav graph:
class ShippingFragment : Fragment() {
private val sharedVm: CheckoutSharedViewModel by navGraphViewModels(R.id.checkout_graph)
}Pattern 3: Surviving Process Death
Process death is the lifecycle event most teams forget to test. The system kills your process while the app is in the background. When the user returns, Android recreates the Activity and Fragment stack from saved state. Everything not explicitly saved is gone.
See also: Event Tracking System Design for Android Applications.
class SearchFragment : Fragment() {
private val viewModel: SearchViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Restore query from SavedStateHandle (survives process death)
val restoredQuery = viewModel.savedQuery.value
if (restoredQuery != null) {
binding.searchInput.setText(restoredQuery)
// Re-fetch results since in-memory cache is gone
viewModel.search(restoredQuery)
}
}
}
class SearchViewModel @Inject constructor(
private val searchRepo: SearchRepository,
private val savedState: SavedStateHandle
) : ViewModel() {
val savedQuery: StateFlow<String?> =
savedState.getStateFlow("query", null)
// Results are NOT saved, they are re-fetched
private val _results = MutableStateFlow<SearchState>(SearchState.Idle)
val results: StateFlow<SearchState> = _results
fun search(query: String) {
savedState["query"] = query
viewModelScope.launch {
_results.value = SearchState.Loading
_results.value = try {
SearchState.Success(searchRepo.search(query))
} catch (e: Exception) {
SearchState.Error(e.message ?: "Search failed")
}
}
}
}Pattern 4: Lifecycle-Aware Cleanup
class MapFragment : Fragment() {
private var mapView: MapView? = null
private var locationCallback: LocationCallback? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mapView = view.findViewById(R.id.map)
locationCallback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
mapView?.updateLocation(result.lastLocation) // safe: tied to view lifecycle
}
}
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
locationClient.requestLocationUpdates(locationRequest, locationCallback!!, Looper.getMainLooper())
}
override fun onStop(owner: LifecycleOwner) {
locationClient.removeLocationUpdates(locationCallback!!)
}
override fun onDestroy(owner: LifecycleOwner) {
mapView = null
locationCallback = null
}
})
}
}Testing Process Death
@Test
fun `search survives process death`() {
val scenario = launchFragmentInContainer<SearchFragment>()
// Simulate user action
scenario.onFragment { fragment ->
fragment.viewModel.search("kotlin coroutines")
}
// Simulate process death
scenario.recreate()
// Verify state is restored
scenario.onFragment { fragment ->
assertEquals("kotlin coroutines", fragment.viewModel.savedQuery.value)
}
}Trade-offs
| Approach | Benefit | Cost |
|---|---|---|
| SavedStateHandle for all state | Survives process death | Limited to Bundle-compatible types |
| viewLifecycleOwner everywhere | Prevents view-related leaks | More verbose than lifecycleScope |
| NavGraph-scoped ViewModels | Clean shared state | Tight coupling to navigation structure |
| Re-fetching vs. persisting | Always fresh data | Network cost, loading states |
| Parcelable state | Fast serialization | Boilerplate, size limits |
Failure Modes
- IllegalStateException: "Can't access the Fragment View's LifecycleOwner when getView() is null": accessing
viewLifecycleOwnerbeforeonCreateViewor afteronDestroyView. Guard withview != nullchecks or move toonViewCreated. - SavedState too large: saving a list of 10,000 items in SavedStateHandle causes a
TransactionTooLargeException. Save only IDs and re-fetch the data. - ViewModel leak through lambda capture: a lambda passed to a repository captures
this@Fragment, preventing garbage collection. Always use ViewModel or application scope for long-running operations. - Configuration change during async operation: a network call completes between destroy and recreate. If the result is posted to a destroyed view, crash. Use StateFlow in ViewModel to decouple the result from the view.
Scaling Considerations
- Establish a lint rule that flags
lifecycleScope.launchusage in Fragments (should useviewLifecycleOwner.lifecycleScope) - Create a base Fragment that enforces correct lifecycle patterns
- Test every screen for process death in CI using
ActivityScenario.recreate() - Monitor
IllegalStateExceptioncrash rates per screen to find lifecycle violations
Observability
- Track process death restoration success rate per screen
- Monitor
onSaveInstanceStatepayload sizes in debug builds - Log lifecycle event sequences for complex flows (multi-fragment transactions)
- Alert on
IllegalStateExceptionspikes that correlate with new releases
Key Takeaways
- The Fragment lifecycle and the Fragment's view lifecycle are different. Conflating them causes leaks and crashes.
- Use
viewLifecycleOwner.lifecycleScopewithrepeatOnLifecyclefor all UI-related collection. - Save minimal state via
SavedStateHandle. Re-fetch derived data after process death. - Scope ViewModels to navigation graphs for shared state across related screens.
- Test process death explicitly. It is the most under-tested lifecycle scenario.
- Treat lifecycle management as infrastructure with lint rules and base classes.
Further Reading
- Understanding the Android Main Thread at Scale: A systems-level look at the Android main thread, its message queue, how work is scheduled and blocked, and strategies for keeping it resp...
- Memory Leaks in Android: Patterns I've Seen in Production: Real-world memory leak patterns from production Android apps, covering lifecycle-bound leaks, static references, listener registration, a...
- Managing Large Dependency Graphs in Android: Strategies for structuring, optimizing, and debugging dependency injection graphs in large Android apps using Dagger/Hilt, covering scopi...
Final Thoughts
Lifecycle management at scale requires moving beyond individual Activity/Fragment understanding to thinking about lifecycle as a system-wide resource. The patterns that work for a 5-screen app break at 50 screens built by 10 teams. Invest in shared infrastructure (base classes, lint rules, CI tests for process death) to prevent lifecycle bugs from becoming the dominant category in your crash reports.
Recommended
Designing an Offline-First Sync Engine for Mobile Apps
A deep dive into building a reliable sync engine that keeps mobile apps functional without connectivity, covering conflict resolution, queue management, and real-world trade-offs.
Jetpack Compose Recomposition: A Deep Dive
A detailed look at how Compose recomposition works under the hood, what triggers it, how the slot table tracks state, and how to control it in production apps.
Event Tracking System Design for Android Applications
A systems-level breakdown of designing an event tracking system for Android, covering batching, schema enforcement, local persistence, and delivery guarantees.