How to Handle Loading and Error States in Android Apps

How to Handle Loading and Error States in Android Apps


When you build your first Android app, one of the earliest UX challenges you’ll face is how to handle loading and error states in Android. Users need clear feedback while data is loading, and friendly, actionable messages when something goes wrong (like no internet, timeouts, or server errors). In this beginner-friendly guide, you’ll learn practical, modern patterns using Kotlin, ViewModel, StateFlow, and Jetpack Compose, plus a simple Java/XML example for classic views. By the end, you’ll know how to show progress bars, display helpful error messages, and manage loading, empty, and error states cleanly in your app.

What is a loading state in an Android app?

How to handle loading and error states in Android shown with spinner, loaded list, and error retry icon
Comparison of loading, success, and error UI states in an Android screen

A loading state is the temporary UI you display while your app is fetching data from a network or database. Typical indicators are a CircularProgressIndicator or LinearProgressIndicator in Material 3. You’ll also manage an “empty” state (when data loads but returns nothing) and an “error” state (when a request fails). The goal is to keep users informed without blocking them unnecessarily, and to provide a clear way to retry or recover.

Core principles for handling loading and error states

MVVM flow to handle loading and error states in Android with icons for loading, success, error, and retry
State flow from UI through ViewModel and Repository to network and back
  • Model UI state explicitly: Represent loading, success, empty, and error as immutable state in your ViewModel and expose it to the UI.
  • Prefer in-place indicators: Show progress where the result will appear. Avoid blocking dialogs for normal operations.
  • Use Snackbars for transient issues: Offer an inline retry action where possible.
  • Map technical failures to friendly messages: Users don’t understand “SocketTimeoutException.” Say “Request timed out. Check your connection and try again.”
  • Be lifecycle-aware: Collect flows with collectAsStateWithLifecycle in Compose, or observe LiveData safely in views.
  • Design for offline and empty states: Cache data, show an offline banner, and let the user retry.

Modern architecture for beginners: ViewModel + StateFlow + Compose

The cleanest beginner setup is:

  • ViewModel holds the source of truth for UI state as a StateFlow.
  • The UI (Jetpack Compose) collects this state and renders the appropriate screen.
  • Use coroutines to run network operations in viewModelScope, mapping errors to user-friendly text.

This approach follows unidirectional data flow and Android’s recommended UI architecture.

End‑to‑end flow: Loading → Success/Empty/Error → Retry

1. Trigger

User opens screen or pulls to refresh

2. ViewModel

_uiState = Loading and start coroutine in viewModelScope

3. Repository

Call API/DB and map exceptions to friendly messages

4. StateFlow emits

Success(items) | Empty | Error(message)

5. UI renders

Compose shows indicator/content/error and optional Snackbar

6. Retry action

User taps “Retry” → ViewModel calls load() again

Step-by-step: manage loading, empty, and error states with ViewModel and StateFlow

1) Define UI state

Kotlin
Code
sealed interface PostsUiState {
    object Loading : PostsUiState
    object Empty : PostsUiState
    data class Success(val items: List<Post>) : PostsUiState
    data class Error(val message: String, val canRetry: Boolean = true) : PostsUiState
}

data class Post(val id: Int, val title: String)

2) Repository: network call with friendly error mapping

Kotlin
Code
class="cd-package">import kotlinx.coroutines.delay
class="cd-package">import retrofit2.HttpException
class="cd-package">import java.io.IOException
class="cd-package">import java.net.SocketTimeoutException
class="cd-package">import kotlin.time.Duration.Companion.seconds

interface ApiService {
    // Example: suspend function returning a list
    suspend fun getPosts(): List<Post>
}

class PostsRepository(class="cd-keyword cd-access">private val api: ApiService) {

    suspend fun fetchPosts(): Result<List<Post>> {
        return try {
            // Optional: simulate latency
            delay(0.5.seconds)
            val posts = api.getPosts()
            Result.success(posts)
        } catch (e: IOException) {
            // No internet / network I/O
            Result.failure(e)
        } catch (e: SocketTimeoutException) {
            Result.failure(e)
        } catch (e: HttpException) {
            Result.failure(e)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

3) ViewModel: expose StateFlow and handle retry

Kotlin
Code
class="cd-package">import androidx.lifecycle.ViewModel
class="cd-package">import androidx.lifecycle.viewModelScope
class="cd-package">import kotlinx.coroutines.flow.MutableSharedFlow
class="cd-package">import kotlinx.coroutines.flow.MutableStateFlow
class="cd-package">import kotlinx.coroutines.flow.StateFlow
class="cd-package">import kotlinx.coroutines.launch
class="cd-package">import retrofit2.HttpException
class="cd-package">import java.io.IOException
class="cd-package">import java.net.SocketTimeoutException
class="cd-package">import kotlinx.coroutines.TimeoutCancellationException

class PostsViewModel(class="cd-keyword cd-access">private val repo: PostsRepository) : ViewModel() {

    class="cd-keyword cd-access">private val _uiState = MutableStateFlow<PostsUiState>(PostsUiState.Loading)
    val uiState: StateFlow<PostsUiState> = _uiState

    // One-off UI events like Snackbars
    sealed interface UiEvent {
        data class ShowSnackbar(val message: String) : UiEvent
    }
    class="cd-keyword cd-access">private val _events = MutableSharedFlow<UiEvent>(extraBufferCapacity = 1)
    val events = _events

    init {
        load()
    }

    fun load() {
        viewModelScope.launch {
            _uiState.value = PostsUiState.Loading
            val result = repo.fetchPosts()
            result.onSuccess { posts ->
                _uiState.value = if (posts.isEmpty()) PostsUiState.Empty else PostsUiState.Success(posts)
            }.onFailure { throwable ->
                val(message, canRetry) = when(throwable) {
                    is IOException -> "No internet connection. Please check your network and try again." to true
                    is SocketTimeoutException, is TimeoutCancellationException -> "Request timed out. Try again." to true
                    is HttpException -> "Server error ${throwable.code()}. Please try again." to true
                    else -> "Something went wrong. Please try again." to true
                }
                _uiState.value = PostsUiState.Error(message, canRetry)
                _events.tryEmit(UiEvent.ShowSnackbar(message))
            }
        }
    }

    fun retry() = load()
}

4) Compose UI: show progress, content, and errors

Kotlin
Code
class="cd-package">import androidx.compose.foundation.layout.*
class="cd-package">import androidx.compose.foundation.lazy.LazyColumn
class="cd-package">import androidx.compose.foundation.lazy.items
class="cd-package">import androidx.compose.material3.*
class="cd-package">import androidx.compose.runtime.*
class="cd-package">import androidx.compose.ui.Alignment
class="cd-package">import androidx.compose.ui.Modifier
class="cd-package">import androidx.compose.ui.text.style.TextAlign
class="cd-package">import androidx.compose.ui.unit.dp
class="cd-package">import androidx.lifecycle.compose.collectAsStateWithLifecycle

class="cd-annotation">@Composable
fun PostsScreen(
    viewModel: PostsViewModel,
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    val snackbarHostState = remember { SnackbarHostState() }

    // Collect one-off snackbar events
    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when(event) {
                is PostsViewModel.UiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
            }
        }
    }

    Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding ->
        Box(Modifier.fillMaxSize().padding(padding)) {
            when(val state = uiState) {
                is PostsUiState.Loading -> {
                    CircularProgressIndicator(
                        modifier = Modifier.align(Alignment.Center)
                    )
                }
                is PostsUiState.Empty -> {
                    Column(
                        modifier = Modifier.fillMaxSize().padding(24.dp),
                        verticalArrangement = Arrangement.Center,
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        Text("Nothing here yet", style = MaterialTheme.typography.titleMedium)
                        Spacer(Modifier.height(8.dp))
                        Text("Try pulling to refresh or check back later.")
                        Spacer(Modifier.height(16.dp))
                        Button(onClick = { viewModel.retry() }) { Text("Refresh") }
                    }
                }
                is PostsUiState.Error -> {
                    Column(
                        modifier = Modifier.fillMaxSize().padding(24.dp),
                        verticalArrangement = Arrangement.Center,
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        Text(
                            state.message,
                            style = MaterialTheme.typography.titleMedium,
                            textAlign = TextAlign.Center
                        )
                        if (state.canRetry) {
                            Spacer(Modifier.height(16.dp))
                            Button(onClick = { viewModel.retry() }) { Text("Retry") }
                        }
                    }
                }
                is PostsUiState.Success -> {
                    LazyColumn(
                        modifier = Modifier.fillMaxSize(),
                        contentPadding = PaddingValues(16.dp),
                        verticalArrangement = Arrangement.spacedBy(12.dp)
                    ) {
                        items(state.items) { post ->
                            ElevatedCard(Modifier.fillMaxWidth()) {
                                Text(
                                    post.title,
                                    modifier = Modifier.padding(16.dp),
                                    style = MaterialTheme.typography.titleMedium
                                )
                            }
                        }
                    }
                }
            }
        }
    }
}

How to show and hide progress bar during API call in Android (Compose and Views)

Compose example above toggles the indicator via PostsUiState.Loading. For classic views, you can control a ProgressBar’s visibility from your Activity/Fragment:

XML
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
android:indeterminate="true"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
Java
ProgressBar progressBar = findViewById(R.id.progressBar);

// Before request
progressBar.setVisibility(View.VISIBLE);

// After success or failure
progressBar.setVisibility(View.GONE);

Use this approach to show progress bar in Android while fetching data and hide it when you have a result or an error.

Kotlin try/catch for network calls and flows

Beginners often ask how to use Kotlin try catch for network calls. In coroutines, wrap your call and map exceptions into UI state:

Kotlin
viewModelScope.launch {
try {
_uiState.value = PostsUiState.Loading
val data = api.getPosts() // suspend
_uiState.value = if (data.isEmpty()) PostsUiState.Empty else PostsUiState.Success(data)
} catch (e: IOException) {
_uiState.value = PostsUiState.Error("No internet connection. Please try again.")
} catch (e: HttpException) {
_uiState.value = PostsUiState.Error("Server error ${e.code()}. Please try again.")
} catch (e: Exception) {
_uiState.value = PostsUiState.Error("Unexpected error. Try again.")
}
}

With Flow, prefer catch and retryWhen for retries:

Kotlin
Code
class="cd-package">import kotlinx.coroutines.flow.flow
class="cd-package">import kotlinx.coroutines.flow.catch
class="cd-package">import kotlinx.coroutines.flow.onStart
class="cd-package">import kotlinx.coroutines.flow.retryWhen
class="cd-package">import java.io.IOException

val postsFlow = flow {
    emit(api.getPosts())
}
.onStart { emit(emptyList()) } // optional
.retryWhen { cause, attempt ->
    // Retry transient errors a couple of times
    cause is IOException && attempt < 2
}
.catch { cause ->
    // Map to UI state at collect site, or emit a special wrapper
    throw cause // Or emit a sealed data wrapper
}

Simple example of try/catch for a network request in Java Android (classic views)

If you are learning Java, here’s a minimal example using Retrofit callbacks to display and hide a ProgressBar and display an error message in Android app:

Java
Code
class="cd-keyword cd-access">public interface ApiService {
    class="cd-annotation">@GET("posts")
    Call<List<Post>> getPosts();
}

class="cd-keyword cd-access">public class Post {
    class="cd-keyword cd-access">public int id;
    class="cd-keyword cd-access">public String title;
}
Java
Code
class="cd-keyword cd-access">public class MainActivity extends AppCompatActivity {

    class="cd-keyword cd-access">private ProgressBar progressBar;
    class="cd-keyword cd-access">private TextView errorText;

    class="cd-annotation">@Override
    class="cd-keyword cd-access">protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        progressBar = findViewById(R.id.progressBar);
        errorText = findViewById(R.id.errorText);

        Retrofit retrofit = new Retrofit.Builder()
                .baseUrl("https://example.com/api/") // Replace with your base URL
                .addConverterFactory(GsonConverterFactory.create())
                .build();

        ApiService api = retrofit.create(ApiService.class);
        fetchPosts(api);
    }

    class="cd-keyword cd-access">private void fetchPosts(ApiService api) {
        showLoading(true);
        api.getPosts().enqueue(new Callback<List<Post>>() {
            class="cd-annotation">@Override
            class="cd-keyword cd-access">public void onResponse(Call<List<Post>> call, Response<List<Post>> response) {
                showLoading(false);
                if (response.isSuccessful() && response.body() != null) {
                    // TODO: update RecyclerView
                } else {
                    showError("Server error " + response.code() + ". Please try again.");
                }
            }

            class="cd-annotation">@Override
            class="cd-keyword cd-access">public void onFailure(Call<List<Post>> call, Throwable t) {
                showLoading(false);
                if (t instanceof IOException) {
                    showError("No internet connection. Please check your network.");
                } else {
                    showError("Something went wrong. Try again.");
                }
            }
        });
    }

    class="cd-keyword cd-access">private void showLoading(boolean loading) {
        progressBar.setVisibility(loading ? View.VISIBLE : View.GONE);
        errorText.setVisibility(View.GONE);
    }

    class="cd-keyword cd-access">private void showError(String message) {
        errorText.setText(message);
        errorText.setVisibility(View.VISIBLE);
    }
}
XML
Code
<!-- activity_main.xml -->
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ProgressBar
        android:id="@+id/progressBar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:indeterminate="true"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

    <TextView
        android:id="@+id/errorText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:textAppearance="?attr/textAppearanceBodyMedium"
        android:gravity="center"
        android:visibility="gone"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_goneMarginTop="16dp"
        android:layout_margin="24dp"/>

</androidx.constraintlayout.widget.ConstraintLayout>

This demonstrates basic Java exception handling in Android without blocking the UI.

Best way to display an error message to users in Android

  • Transient or recoverable: Use a Snackbar with a Retry action.
  • Inline for form fields: Show helper/error text near inputs.
  • Blocking/critical: Use a dialog only when the user must decide (e.g., destructive actions).
  • Offline: Show a non-blocking “You’re offline” banner and allow manual retry.

This strikes the right balance between visibility and non-intrusiveness.

Situation Recommended UI Why Avoid
Initial screen load fails Full‑screen error with “Retry” + Snackbar High visibility and a clear recovery path Multiple Toasts or silent failures
Append (pagination) fails List footer error row + “Retry” Keeps context; user stays in the list Full‑screen overlays
Form validation error Inline field error text Immediate, specific guidance Global Snackbars without context
Offline / no internet Non‑blocking offline banner + Retry Keeps cached content visible Blocking dialogs for every request
Critical/irreversible action Modal dialog with confirm/cancel Requires explicit user decision Silent failures or Snackbars alone

Lists and pagination: handle API errors in Android with Paging 3

For paginated lists, Paging 3 exposes LoadState for refresh, prepend, and append. In Compose, you can render full-screen loading/error for the refresh state and show item-level footers for append:

Kotlin
Code
class="cd-package">import androidx.paging.compose.collectAsLazyPagingItems
class="cd-package">import androidx.paging.LoadState

class="cd-annotation">@Composable
fun PagedPostsScreen(viewModel: PagedPostsViewModel) {
    val items = viewModel.posts.collectAsLazyPagingItems()

    when(val refresh = items.loadState.refresh) {
        is LoadState.Loading -> Box(Modifier.fillMaxSize()) {
            CircularProgressIndicator(Modifier.align(Alignment.Center))
        }
        is LoadState.Error -> Box(Modifier.fillMaxSize()) {
            Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) {
                Text("Failed to load posts. ${refresh.error.message ?: ""}")
                Spacer(Modifier.height(12.dp))
                Button(onClick = { items.retry() }) { Text("Retry") }
            }
        }
        is LoadState.NotLoading -> {
            LazyColumn {
                items(items.itemCount) { index ->
                    val post = items[index]
                    if (post != null) {
                        Text(post.title, Modifier.padding(16.dp))
                    }
                }
                when(val append = items.loadState.append) {
                    is LoadState.Loading -> item { LinearProgressIndicator(Modifier.fillMaxWidth()) }
                    is LoadState.Error -> item {
                        Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween) {
                            Text("Couldn’t load more.")
                            TextButton(onClick = { items.retry() }) { Text("Retry") }
                        }
                    }
                    else -> Unit
                }
            }
        }
    }
}

With RecyclerView, use LoadStateAdapter to show headers/footers for loading and errors with a retry button.

Beginner guide to handling errors with Kotlin coroutines

  • Perform network work in viewModelScope on a background dispatcher.
  • Map categories: no internet (IOException), timeout, HTTP error (HttpException), unknown.
  • Use retryWhen for brief automatic retries on transient failures; avoid infinite loops.
  • Cancel work on lifecycle end by scoping to the ViewModel; Compose collectors should use collectAsStateWithLifecycle.

How do I manage loading, empty, and error states with ViewModel and LiveData?

While Flow/StateFlow is great for new code, LiveData remains supported. You can expose the same sealed UI state as LiveData<PostsUiState> and observe it in your Activity/Fragment. The rendering logic is identical: switch on Loading/Empty/Success/Error and update views.

Best way to show a no internet connection error in Android

  • Detect IOException or use connectivity APIs to infer offline status.
  • Show an inline message like “You’re offline.” Keep content visible if cached.
  • Offer “Retry” or “Try again when online.” Consider a non-blocking banner.
  • For mission-critical actions, allow a manual retry and optionally queue work for later with WorkManager.

Offline-first tips

  • Cache data in Room and show cached content while refreshing.
  • Surface an offline banner when write operations can’t complete.
  • Use WorkManager to schedule sync/retry when connectivity returns.
  • Make it clear when content is stale and allow manual refresh.

How do I show a progress bar while fetching data in Android?

In Compose, render a CircularProgressIndicator when your UI state is Loading. In classic views, toggle ProgressBar visibility before and after the network call. Use a determinate LinearProgressIndicator when you can measure progress (like file uploads), and an indeterminate indicator when you can’t (most API calls).

Launch checklist: Loading and error states

  • ✓ Use a sealed UI state: Loading, Success(data), Empty, Error(message)
  • ✓ Run network work in viewModelScope; expose StateFlow or LiveData
  • ✓ Map exceptions to friendly messages (IOException, timeouts, HttpException, unknown)
  • ✓ Show in-place indicators; reserve dialogs for critical decisions
  • ✓ Provide a visible Retry action for Error and Empty (when appropriate)
  • ✓ Keep cached data visible; add an offline banner for no connectivity
  • ✓ For lists, wire Paging 3 LoadState with retry in headers/footers
  • ✓ Collect with lifecycle awareness (collectAsStateWithLifecycle)
  • ✓ Test: airplane mode, slow/timeout, HTTP 500, empty response, rotation
  • ✓ Log errors with context, but never show raw stack traces to users

Output / Result

After following this guide, your app will:

  • Show a smooth loading indicator during API calls without blocking the screen unnecessarily.
  • Display clear, user-friendly error messages with a visible Retry action.
  • Handle empty results gracefully with guidance for the user.
  • Support pagination with proper load-state handling for refresh and append.
  • Remain responsive and lifecycle-safe using ViewModel, StateFlow, and Compose.

FAQ

What is a loading state in an Android app?

It’s the temporary UI you show while data is being fetched. Use in-place indicators (Circular/Linear progress) and keep the UI responsive.

How do I show a progress bar while fetching data in Android?

Compose: render a CircularProgressIndicator when state is Loading. Classic views: show a ProgressBar before the request, hide it in success/failure callbacks.

How can beginners handle API errors in Kotlin or Java?

Kotlin: use try/catch around suspend calls and map exceptions to friendly messages. Java: use Retrofit’s enqueue callbacks, show/hide a ProgressBar, and show a Snackbar/Toast or inline error text on failure.

What is the best way to display an error message to users in Android?

Use Snackbars for transient issues with an optional Retry action. Use inline text for form errors. Reserve dialogs for critical or blocking situations.

How do I manage loading, empty, and error states with ViewModel and LiveData?

Expose a sealed UI state via LiveData from your ViewModel. Observe it in your Activity/Fragment and update the UI based on the current state (Loading/Empty/Success/Error). The pattern mirrors the Flow/Compose approach.

Sources / Further reading

With these patterns and examples, you now have a reliable, modern way to handle API errors in Android and to show progress clearly. Whether you prefer Compose with Kotlin or classic views with Java, start modeling your UI state explicitly and your users will instantly feel the difference.

Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted