Every real app needs data from the internet. A weather app calls a weather API. A social app loads posts from a server. A store app fetches products from a backend.

Retrofit is the most popular library for making HTTP requests in Android. In this tutorial, you will learn how to use it with Compose to build a screen that loads, displays, and handles errors from a real API.

What is Retrofit?

Retrofit is an HTTP client library by Square. It turns your API into a Kotlin interface:

interface UserApi {
    @GET("users")
    suspend fun getUsers(): List<User>
}

That is it. Retrofit handles the networking, JSON parsing, and threading. You just call api.getUsers() and get a list of objects back.

Setup

Add these dependencies to your build.gradle.kts:

// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.11.0")

// Kotlin Serialization converter (to parse JSON)
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")

// OkHttp (Retrofit uses it internally)
implementation("com.squareup.okhttp3:okhttp:4.12.0")

Add the serialization plugin to your app build.gradle.kts:

plugins {
    kotlin("plugin.serialization")
}

And add internet permission to AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET" />

Step 1: Define the Data Model

We will use a free public API — JSONPlaceholder. It returns fake users, posts, and comments.

@Serializable
data class User(
    val id: Int,
    val name: String,
    val email: String,
    val phone: String
)

The @Serializable annotation tells Kotlin Serialization how to parse the JSON into this class.

Step 2: Create the API Interface

interface UserApi {
    @GET("users")
    suspend fun getUsers(): List<User>

    @GET("users/{id}")
    suspend fun getUserById(@Path("id") userId: Int): User
}

Each function is a network request:

  • @GET("users") → calls https://jsonplaceholder.typicode.com/users
  • suspend → runs on a background thread, doesn’t block the UI
  • Return type → Retrofit automatically parses the JSON into Kotlin objects

Step 3: Create the Retrofit Instance

object RetrofitClient {
    private val json = Json {
        ignoreUnknownKeys = true  // Don't crash if API returns extra fields
    }

    private val retrofit = Retrofit.Builder()
        .baseUrl("https://jsonplaceholder.typicode.com/")
        .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
        .build()

    val userApi: UserApi = retrofit.create(UserApi::class.java)
}

We use ignoreUnknownKeys = true because real APIs often return more fields than we need. Without this, Kotlin Serialization would crash on any unknown field.

Step 4: Create the ViewModel

The ViewModel calls the API and exposes the result as state:

data class UserListState(
    val users: List<User> = emptyList(),
    val isLoading: Boolean = true,
    val error: String? = null
)

class UserListViewModel : ViewModel() {
    private val _state = MutableStateFlow(UserListState())
    val state: StateFlow<UserListState> = _state.asStateFlow()

    init {
        loadUsers()
    }

    private fun loadUsers() {
        viewModelScope.launch {
            _state.update { it.copy(isLoading = true, error = null) }

            try {
                val users = RetrofitClient.userApi.getUsers()
                _state.update { it.copy(users = users, isLoading = false) }
            } catch (e: Exception) {
                _state.update {
                    it.copy(
                        error = e.message ?: "Unknown error",
                        isLoading = false
                    )
                }
            }
        }
    }

    fun retry() {
        loadUsers()
    }
}

The pattern:

  1. Set loading to true
  2. Call the API inside a try/catch
  3. On success → update state with data
  4. On failure → update state with error message

This is the same pattern for EVERY API call in every app. Learn it once, use it everywhere.

Step 5: Build the UI

@Composable
fun UserListScreen(viewModel: UserListViewModel = viewModel()) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    when {
        state.isLoading -> LoadingState()
        state.error != null -> ErrorState(
            message = state.error!!,
            onRetry = { viewModel.retry() }
        )
        else -> UserList(users = state.users)
    }
}

@Composable
fun LoadingState() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            CircularProgressIndicator()
            Spacer(modifier = Modifier.height(8.dp))
            Text("Loading users...")
        }
    }
}

@Composable
fun ErrorState(message: String, onRetry: () -> Unit) {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            Text("Something went wrong", fontWeight = FontWeight.Bold)
            Spacer(modifier = Modifier.height(4.dp))
            Text(message, color = MaterialTheme.colorScheme.error)
            Spacer(modifier = Modifier.height(16.dp))
            Button(onClick = onRetry) {
                Text("Retry")
            }
        }
    }
}

@Composable
fun UserList(users: List<User>) {
    LazyColumn(
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(users, key = { it.id }) { user ->
            UserCard(user)
        }
    }
}

@Composable
fun UserCard(user: User) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        shape = RoundedCornerShape(12.dp),
        color = MaterialTheme.colorScheme.surfaceVariant
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(user.name, fontWeight = FontWeight.Bold)
            Text(user.email, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
            Text(user.phone, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
        }
    }
}

Three UI states:

  • Loading — spinner + text
  • Error — error message + retry button
  • Success — list of user cards

This is the standard pattern. You will use it in every screen that loads data.

Error Handling Best Practices

Catch Specific Exceptions

try {
    val users = api.getUsers()
    _state.update { it.copy(users = users, isLoading = false) }
} catch (e: java.net.UnknownHostException) {
    _state.update { it.copy(error = "No internet connection", isLoading = false) }
} catch (e: java.net.SocketTimeoutException) {
    _state.update { it.copy(error = "Request timed out. Try again.", isLoading = false) }
} catch (e: retrofit2.HttpException) {
    _state.update { it.copy(error = "Server error: ${e.code()}", isLoading = false) }
} catch (e: Exception) {
    _state.update { it.copy(error = e.message ?: "Unknown error", isLoading = false) }
}

Give users helpful messages, not technical stack traces.

Add a Timeout

private val okHttpClient = OkHttpClient.Builder()
    .connectTimeout(10, TimeUnit.SECONDS)
    .readTimeout(10, TimeUnit.SECONDS)
    .build()

private val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .client(okHttpClient)
    .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
    .build()

Add Logging (For Debugging)

implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
val loggingInterceptor = HttpLoggingInterceptor().apply {
    level = HttpLoggingInterceptor.Level.BODY
}

val okHttpClient = OkHttpClient.Builder()
    .addInterceptor(loggingInterceptor)
    .build()

This logs every request and response in Logcat. Very useful during development. Remove it in production.

POST Request — Sending Data

Retrofit also handles sending data to an API:

@Serializable
data class CreateUserRequest(
    val name: String,
    val email: String
)

@Serializable
data class CreateUserResponse(
    val id: Int,
    val name: String,
    val email: String
)

interface UserApi {
    @POST("users")
    suspend fun createUser(@Body request: CreateUserRequest): CreateUserResponse
}

Usage in ViewModel:

fun createUser(name: String, email: String) {
    viewModelScope.launch {
        try {
            val response = RetrofitClient.userApi.createUser(
                CreateUserRequest(name = name, email = email)
            )
            // User created successfully with id: response.id
        } catch (e: Exception) {
            // Handle error
        }
    }
}

Common Mistakes

Mistake 1: Making API Calls in Composables

// BAD — runs on every recomposition
@Composable
fun UserScreen() {
    val users = api.getUsers() // This is wrong on many levels
}

// GOOD — call API in ViewModel, observe in Composable
@Composable
fun UserScreen(viewModel: UserListViewModel = viewModel()) {
    val state by viewModel.state.collectAsStateWithLifecycle()
}

Mistake 2: Not Handling Errors

// BAD — crashes on network error
val users = api.getUsers()

// GOOD — try/catch with user-friendly message
try {
    val users = api.getUsers()
} catch (e: Exception) {
    _state.update { it.copy(error = "Could not load data") }
}

Mistake 3: Missing Internet Permission

If you forget to add <uses-permission android:name="android.permission.INTERNET" /> to your AndroidManifest.xml, Retrofit will fail with a confusing error.

Mistake 4: Not Using ignoreUnknownKeys

// BAD — crashes when API adds a new field
val json = Json { }

// GOOD — ignores extra fields
val json = Json { ignoreUnknownKeys = true }

Quick Reference

ComponentWhat It Does
@GET("path")HTTP GET request
@POST("path")HTTP POST request
@Path("id")URL path parameter
@Query("key")URL query parameter
@BodyRequest body (JSON)
@HeadersCustom HTTP headers
suspend funNon-blocking network call
ignoreUnknownKeysDon’t crash on extra JSON fields

Result

Here is what the app looks like when you run the code from this tutorial:

Light ModeDark Mode
Tutorial 12 LightTutorial 12 Dark

Source Code

The complete working code for this tutorial is on GitHub:

View source code on GitHub →

What’s Next?

In the next tutorial, we will learn about Room Database — saving data locally so your app works offline. Combined with Retrofit, you can build an app that loads from the internet and caches locally.

See you there.