Fixing Jetpack Compose Loading Indicator Race Condition in StateFlow

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 the init block of the ViewModel.
  • State Overwrite: Because _uiState is initialized with HomeUIState.Loading, the first value emitted to collectors is already “Loading”. However, the viewModelScope.launch block 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.Loading and the subsequent _uiState.value = HomeUIState.Success is shorter than a single Compose Recomposition frame.
  • Recomposition Skipping: Compose observes state changes. If a state changes from Loading to Success within the same execution loop before the UI has had a chance to draw the Loading state 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 suspend functions inside init blocks 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 an Idle or Initial state. This forces the Coroutine to explicitly trigger a transition to Loading, making the state change more “observable” to the Compose runtime.
  • Side-Effect Management: Use LaunchedEffect in the UI layer to trigger data loading based on specific lifecycle events rather than relying solely on ViewModel.init.
  • Testing for Race Conditions: Implement Turbine (a library for testing Flows) in unit tests to assert that the Loading state is actually emitted and collected before the Success state.
  • 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.

Leave a Comment