Lifecycle, LiveData & repeatOnLifecycle Explained

ARCHITECTURE › Components

LiveData is Android's lifecycle aware observable holder. You write a value in with setValue or postValue, and every registered observer gets an onChanged callback when it changes, similar to a single value stream you can also read synchronously at any time. The property that matters most for interviews is that LiveData only delivers to observers whose LifecycleOwner is active, meaning STARTED or RESUMED. If you register an observer while the owner is only CREATED, for example an activity that has been stopped but not destroyed, that observer is registered but inactive. It sits there quietly and does not receive onChanged calls until the owner starts again, at which point it is handed the most recently set value, not a replay of every value that changed while it was inactive. This is what makes LiveData safe by default: you cannot accidentally push an update into a hidden or destroyed view. Once the owner reaches DESTROYED, LiveData removes the observer automatically, so the common observe(owner, observer) call needs no manual cleanup.

Two related but distinct types sit underneath all of this. Lifecycle.State, values like INITIALIZED, CREATED, STARTED, RESUMED, and DESTROYED, describes where an owner currently is. Lifecycle.Event, values like ON_CREATE and ON_START, describes the transition that moved it there. To react to those transitions in your own code, the modern approach is to implement DefaultLifecycleObserver and override plain methods like onStart(owner) and onStop(owner). The older approach, a LifecycleObserver with methods annotated @OnLifecycleEvent, is deprecated. It worked by scanning your class with reflection and annotation processing at runtime to figure out which method to call for which event, which is slower and less type safe than DefaultLifecycleObserver's plain overridable callbacks, where the compiler checks your method signatures directly.

class MyObserver : DefaultLifecycleObserver {
    override fun onStart(owner: LifecycleOwner) { startWork() }
    override fun onStop(owner: LifecycleOwner)  { stopWork() }
}
lifecycle.addObserver(MyObserver())

MutableLiveData gives you two ways to write a value, and interviewers expect you to know they are not interchangeable. setValue, or the value property setter, must run on the main thread and applies synchronously. Call it from a background thread and it throws. postValue can be called from any thread, and instead of writing immediately it schedules the update to run on the main thread shortly afterward. The gotcha lives in what happens when you call postValue several times before the main thread gets a chance to process the first one. Those pending updates coalesce: only the most recently posted value is guaranteed to reach observers, and the values in between can be silently dropped. That behavior is fine when you only care that the latest state wins, but it is a real trap if you assumed every posted value would be delivered.

Three transform tools sit on top of LiveData, and two of them work on a single upstream source. map applies a synchronous transform to each value the source emits, for example turning a User into a display name string, nothing async, nothing that produces a new LiveData. switchMap is for the case where your transform itself needs to return a new LiveData for each input value. The classic textbook example is a userId LiveData feeding a repository lookup, userId.switchMap { id -> repository.getUser(id) }. Each time userId changes, switchMap swaps to the new LiveData that getUser returns and automatically stops observing the old one. Reach for switchMap whenever the next step is itself an asynchronous, LiveData returning operation keyed on the input, not just a synchronous formatting step.

The third transform tool, MediatorLiveData, is for a different shape of problem: you have more than one independent LiveData source and you want a single LiveData that reacts whenever any of them changes. You create a MediatorLiveData and call addSource once per input source, along with a lambda for what to do when that particular source fires. A common example is combining a cached LiveData and a network LiveData into one observable that a UI can watch without caring which source actually produced the update. Neither map nor switchMap can do this, they each only ever look at a single upstream source, so the moment a requirement involves merging two or more independently changing sources, MediatorLiveData is the tool the interviewer is fishing for.

observeForever is the escape hatch for observing a LiveData with no LifecycleOwner at all, useful for a long lived singleton or a repository that needs to watch a value outside of any screen. The risk is right there in the name: with no owner to tie itself to, the observer never becomes inactive, and LiveData never removes it automatically. If you call observeForever and forget to call removeObserver yourself, that observer stays registered for as long as the process lives, holding a reference to everything it closes over, which is a textbook memory leak. Compare that with the normal observe(owner, observer) call, which is auto-removed the moment the owner reaches DESTROYED. observeForever trades that safety for control, and the price of that control is that cleanup becomes your job.

Fragments have a subtlety that catches people who only know LiveData from Activities: a Fragment's view and the Fragment instance itself do not share one lifecycle. A Fragment can stay alive on the back stack while its view is destroyed and later recreated. If you observe a LiveData using the Fragment itself as the LifecycleOwner, that observer keeps running across a view destroy and recreate, which can mean updating a view that no longer exists, or ending up with duplicate observers stacked on top of each other after the Fragment returns from the back stack. The fix is to observe with viewLifecycleOwner instead, which is scoped to the current view. It gets torn down and recreated in step with the view, so the observer is correctly removed and re-registered exactly when the view is.

Collecting a Flow safely in the View system has one correct answer today: Lifecycle.repeatOnLifecycle. It is a suspend function, and the way it behaves is the whole point. Every time the lifecycle reaches the state you pass in, usually STARTED, it launches your block in a brand new coroutine. The instant the lifecycle drops back below that state, it stops that coroutine outright, not merely pauses it, ends it completely. Then if the lifecycle returns to STARTED again, it launches a fresh coroutine and repeats. This cycle continues for as long as the Lifecycle exists, until it finally reaches DESTROYED. Because the stop is real, whatever the block is doing, most commonly collecting a Flow, genuinely stops doing work while the screen is backgrounded, instead of quietly continuing behind the scenes.

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { state -> render(state) }
    }
}

One property of repeatOnLifecycle trips people up in code review: because it is a suspend function that only returns once the Lifecycle reaches DESTROYED, any code you write after it inside the same coroutine is effectively unreachable for the entire time the screen is alive. If you write repeatOnLifecycle(STARTED) { collectA() } followed by collectB() in the same launch block, collectB never gets a meaningful chance to run while the screen is in use. It only executes after DESTROYED, when it is already too late to matter. If you need more than one independent collector, each one needs its own launch placed inside the repeatOnLifecycle block, not sequenced after it. This is also why you only need to write repeatOnLifecycle once, typically in onCreate. It internally handles the start and stop cancel-and-relaunch cycle itself, no separate onStart or onStop wiring required.

Before repeatOnLifecycle existed, the common pattern was lifecycleScope.launchWhenStarted, and it is now deprecated for a specific reason that follows directly from the cancel versus pause distinction. launchWhenStarted only suspends the coroutine's body when the lifecycle drops below STARTED, it does not cancel it. That means a Flow.collect sitting inside it does not actually stop when the screen goes to the background. The collection, and whatever is producing values for it upstream, a network socket, a database query, a sensor listener, keeps running the whole time the screen is hidden, quietly burning battery and other resources for no visible benefit. repeatOnLifecycle solves exactly this by cancelling the coroutine outright instead of merely pausing it, so the upstream producer genuinely stops.

Jetpack Compose has its own answer to the same problem, and interviewers expect you to know it is not just collectAsState. Plain collectAsState, or a raw LaunchedEffect that calls collect, has no lifecycle gating at all, it keeps collecting even while the composable's screen is off screen or the app is backgrounded. The lifecycle aware equivalent is collectAsStateWithLifecycle, from the lifecycle-runtime-compose artifact. It collects a Flow, typically a ViewModel's StateFlow, only while the lifecycle is at least STARTED, and stops automatically once it drops below that, resuming again when the screen comes back. It is effectively repeatOnLifecycle's behavior wrapped up as a Compose state producer, so you get the same safety without writing the boilerplate by hand.

Zooming out to architecture: Flow has no Android dependency and composes cleanly with operators, which is exactly why it belongs in the data layer, inside repositories. LiveData's own transforms, map and switchMap, always run on the main thread, which is a poor fit for a repository that should stay Android agnostic and off the main thread. The ViewModel is the bridge between the two. It takes the repository's Flow and exposes it to the UI either as a StateFlow, built with stateIn and a SharingStarted policy, for Compose screens, or via asLiveData() for View system code that still expects LiveData. The rule of thumb interviewers are listening for: keep Flow in the data layer, and let the ViewModel decide which lifecycle friendly shape, StateFlow or LiveData, the UI actually needs.

class UserRepository {
    fun getUser(id: String): Flow<User> = flow { emit(api.fetch(id)) }
}

class UserViewModel(repo: UserRepository) : ViewModel() {
    val user: StateFlow<User?> = repo.getUser("123")
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
}

One last distinction worth nailing down: StateFlow versus a plain SharedFlow, since both are commonly used to expose ViewModel state. StateFlow is a conflated state holder. It requires an initial value at construction, it always exposes a current value you can read synchronously, and it replays only the latest value to a new collector, which is exactly the shape UI state needs. A screen that starts observing late should immediately see the current state, not a backlog of every state that came before. SharedFlow is more general and configurable. It has no required initial value, and you choose its replay count and buffer behavior yourself, which makes it a better fit for one-off events, like a snackbar message or a navigation trigger, that should not be replayed as a persistent current value.

Back to Lifecycle, LiveData & repeatOnLifecycle