Understanding Android Lifecycle at Scale

Dhruval Dhameliya·November 17, 2025·7 min read

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 onStart outlives 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 repeatOnLifecycle block 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.

ComponentLifecycle OwnerSurvives Config ChangeSurvives Process Death
ApplicationProcessYesNo
ActivityActivityNo (recreated)No (recreated)
FragmentFragmentNo (recreated)No (recreated)
ViewModelViewModelStoreOwnerYesNo
SavedStateHandleSavedStateRegistryYesYes
Navigation backstackNavControllerYesYes (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

ApproachBenefitCost
SavedStateHandle for all stateSurvives process deathLimited to Bundle-compatible types
viewLifecycleOwner everywherePrevents view-related leaksMore verbose than lifecycleScope
NavGraph-scoped ViewModelsClean shared stateTight coupling to navigation structure
Re-fetching vs. persistingAlways fresh dataNetwork cost, loading states
Parcelable stateFast serializationBoilerplate, size limits

Failure Modes

  • IllegalStateException: "Can't access the Fragment View's LifecycleOwner when getView() is null": accessing viewLifecycleOwner before onCreateView or after onDestroyView. Guard with view != null checks or move to onViewCreated.
  • 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.launch usage in Fragments (should use viewLifecycleOwner.lifecycleScope)
  • Create a base Fragment that enforces correct lifecycle patterns
  • Test every screen for process death in CI using ActivityScenario.recreate()
  • Monitor IllegalStateException crash rates per screen to find lifecycle violations

Observability

  • Track process death restoration success rate per screen
  • Monitor onSaveInstanceState payload sizes in debug builds
  • Log lifecycle event sequences for complex flows (multi-fragment transactions)
  • Alert on IllegalStateException spikes 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.lifecycleScope with repeatOnLifecycle for 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

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