Summary
A production issue was identified where the loading indicator (CircularProgressIndicator) failed to render during asynchronous data fetching in a Jetpack Compose application. Despite the HomeViewModel explicitly setting the state to HomeUIState.Loading, the UI skipped the loading phase and jumped directly to either the Success or Error state. This resulted in a poor user experience (UX) where the application appeared frozen or non-responsive during network latency.
Root Cause
The primary cause is a race condition between state initialization and the Coroutine execution lifecycle, compounded by how MutableStateFlow handles immediate state updates.
- Immediate Execution: The
loadData()function is called inside theinitblock of theViewModel. - State Overwrite: Because
_uiStateis initialized withHomeUIState.Loading, the first value emitted to collectors is already “Loading”. However, theviewModelScope.launchblock begins executing almost instantly. - Micro-task Scheduling: In many environments, especially with fast local networks or mocked data, the time between setting
_uiState.value = HomeUIState.Loadingand the subsequent_uiState.value = HomeUIState.Successis shorter than a single Compose Recomposition frame. - Recomposition Skipping: Compose observes state changes. If a state changes from
LoadingtoSuccesswithin the same execution loop before the UI has had a chance to draw theLoadingstate to the screen, the UI engine simply renders the final state.
Why This Happens in Real Systems
In high-performance or low-latency environments, this is a common architectural “blind spot”:
- Network Speed: On high-speed 5G or local dev environments, the “Loading” state exists for only a few milliseconds.
- State Atomicity: Developers often treat state transitions as a sequence of discrete events, forgetting that UI rendering is decoupled from state mutation.
- Synchronous-looking Asynchrony: Using
suspendfunctions insideinitblocks can create an illusion of sequentiality that masks the fact that the UI hasn’t actually processed the first state change yet.
Real-World Impact
- Perceived Latency: Users perceive the app as “stuck” because there is no visual feedback that a background task is running.
- User Frustration: Users may tap buttons multiple times (double-submitting forms) because they believe the application has crashed.
- Increased Support Load: Frequent “app is frozen” reports in production logs.
Example or Code (if necessary and relevant)
// The problematic pattern
class HomeViewModel(...) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUIState.Loading)
init {
viewModelScope.launch {
// Even if we set Loading here, the next line might execute
// before the UI draws the loading spinner.
_uiState.value = HomeUIState.Loading
val users = userRepo.getUser()
_uiState.value = HomeUIState.Success(users)
}
}
}
// The robust pattern
sealed interface HomeUIState {
object Idle : HomeUIState
object Loading : HomeUIState
data class Success(val list: List) : HomeUIState
data class Error(val message: String) : HomeUIState
object NoData : HomeUIState
}
class HomeViewModel(...) : ViewModel() {
// Start with Idle to ensure a clean state transition
private val _uiState = MutableStateFlow(HomeUIState.Idle)
val uiState: StateFlow = _uiState
init {
loadData()
}
private fun loadData() {
viewModelScope.launch {
_uiState.value = HomeUIState.Loading
try {
val users = userRepo.getUser()
_uiState.value = if (users.isEmpty()) HomeUIState.NoData else HomeUIState.Success(users)
} catch (e: Exception) {
_uiState.value = HomeUIState.Error(e.localizedMessage ?: "Error")
}
}
}
}
How Senior Engineers Fix It
Senior engineers approach this by ensuring state predictability and UI visibility:
- Explicit State Transitions: Instead of initializing the StateFlow with
Loading, initialize it with anIdleorInitialstate. This forces the Coroutine to explicitly trigger a transition toLoading, making the state change more “observable” to the Compose runtime. - Side-Effect Management: Use
LaunchedEffectin the UI layer to trigger data loading based on specific lifecycle events rather than relying solely onViewModel.init. - Testing for Race Conditions: Implement Turbine (a library for testing Flows) in unit tests to assert that the
Loadingstate is actually emitted and collected before theSuccessstate. - Adding Artificial Delays in Dev: During debugging, senior engineers often inject a
delay(1000)in the repository to verify that the UI handles the loading state correctly.
Why Juniors Miss It
- Mental Model Mismatch: Juniors often think in terms of sequential logic (“First I set loading, then I fetch, then I set success”) rather than event-driven streams (“I emit a signal, and at some unknown time, the UI reacts”).
- Focus on “Happy Path”: Most learning resources focus on making the data appear, not on the temporal gaps between data states.
- Lack of Profiling Experience: Juniors rarely use tools like the Layout Inspector or Recomposition Counters to see if a specific state (like
Loading) was even attempted to be drawn.