Every Android app fetches data from a server. And for years, that meant AsyncTask, then RxJava, then a mix of callbacks and thread management that left most codebases looking like spaghetti. Then Kotlin Coroutines arrived, and suddenly network calls became as readable as sequential code.
Combine Retrofit — the industry standard HTTP client for Android — with Kotlin Coroutines, and you get the cleanest, most readable approach to network calls available today. No callbacks, no thread management, no RxJava chains. Just suspend fun and results. This guide covers setup, a real API call, error handling, and wiring it all into a ViewModel the right way.
Setup — Dependencies
Add these to your build.gradle (app):
dependencies {
// Retrofit — HTTP client
implementation "com.squareup.retrofit2:retrofit:2.11.0"
// Gson converter — converts JSON to Kotlin data classes
implementation "com.squareup.retrofit2:converter-gson:2.11.0"
// OkHttp logging interceptor — logs request/response in Logcat
implementation "com.squareup.okhttp3:logging-interceptor:4.12.0"
// Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
// ViewModel + Lifecycle
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.0"
}
Add internet permission to AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET"/>
Step 1 — Define Your Data Class
Retrofit maps JSON responses directly to Kotlin data classes using Gson. The field names in the data class must match the JSON keys (or use @SerializedName to map different names):
import com.google.gson.annotations.SerializedName
data class Article(
val id: Int,
val title: String,
val body: String,
@SerializedName("user_id") val userId: Int, // maps JSON "user_id" to userId
@SerializedName("created_at") val createdAt: String
)
// For list responses
data class ArticleResponse(
val articles: List<Article>,
val total: Int,
val page: Int
)
Step 2 — Define the API Interface
This is where Retrofit shines. You define your endpoints as simple interface functions with suspend — Retrofit handles all the threading:
import retrofit2.http.*
interface ArticleApi {
// GET request — fetch list of articles
@GET("articles")
suspend fun getArticles(
@Query("page") page: Int = 1,
@Query("limit") limit: Int = 20
): List<Article>
// GET with path parameter
@GET("articles/{id}")
suspend fun getArticleById(
@Path("id") articleId: Int
): Article
// POST request — create article
@POST("articles")
suspend fun createArticle(
@Body article: Article
): Article
// PUT request — update article
@PUT("articles/{id}")
suspend fun updateArticle(
@Path("id") articleId: Int,
@Body article: Article
): Article
// DELETE request
@DELETE("articles/{id}")
suspend fun deleteArticle(
@Path("id") articleId: Int
): Unit
// With auth header
@GET("profile")
suspend fun getUserProfile(
@Header("Authorization") token: String
): UserProfile
}
Step 3 — Build the Retrofit Instance
Create a singleton Retrofit client. The logging interceptor is your best friend during development — it prints full request and response bodies to Logcat:
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object RetrofitClient {
private const val BASE_URL = "https://api.yourserver.com/v1/"
// Logging interceptor — only enable in DEBUG builds
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY // Full request + response
} else {
HttpLoggingInterceptor.Level.NONE // No logging in production
}
}
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
// Add auth interceptor for Bearer token on all requests
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.addHeader("Authorization", "Bearer ${TokenManager.getToken()}")
.build()
chain.proceed(request)
}
.build()
val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
val articleApi: ArticleApi = retrofit.create(ArticleApi::class.java)
}
Step 4 — Repository Layer with Error Handling
The Repository wraps API calls and converts raw responses into a Result — so the ViewModel always gets either a success or a typed error, never a raw exception:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ArticleRepository(
private val api: ArticleApi = RetrofitClient.articleApi
) {
suspend fun getArticles(page: Int = 1): Result<List<Article>> =
withContext(Dispatchers.IO) {
runCatching {
api.getArticles(page = page)
}.fold(
onSuccess = { Result.success(it) },
onFailure = { Result.failure(mapError(it)) }
)
}
suspend fun getArticleById(id: Int): Result<Article> =
withContext(Dispatchers.IO) {
runCatching { api.getArticleById(id) }
}
// Map raw throwable to meaningful error types
private fun mapError(throwable: Throwable): Exception {
return when (throwable) {
is retrofit2.HttpException -> when (throwable.code()) {
401 -> UnauthorizedException("Session expired. Please log in again.")
403 -> ForbiddenException("You don't have access to this resource.")
404 -> NotFoundException("Content not found.")
500 -> ServerException("Server error. Please try again later.")
else -> ApiException("Request failed: ${throwable.code()}")
}
is java.net.UnknownHostException -> NetworkException("No internet connection.")
is java.net.SocketTimeoutException -> TimeoutException("Request timed out.")
else -> UnknownException(throwable.message ?: "Unknown error")
}
}
}
// Custom error types
class UnauthorizedException(message: String) : Exception(message)
class ForbiddenException(message: String) : Exception(message)
class NotFoundException(message: String) : Exception(message)
class ServerException(message: String) : Exception(message)
class ApiException(message: String) : Exception(message)
class NetworkException(message: String) : Exception(message)
class TimeoutException(message: String) : Exception(message)
class UnknownException(message: String) : Exception(message)
Step 5 — ViewModel with StateFlow
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class ArticleViewModel(
private val repository: ArticleRepository = ArticleRepository()
) : ViewModel() {
private val _uiState = MutableStateFlow<ArticleUiState>(ArticleUiState.Loading)
val uiState: StateFlow<ArticleUiState> = _uiState
init {
loadArticles()
}
fun loadArticles(page: Int = 1) {
viewModelScope.launch {
_uiState.value = ArticleUiState.Loading
repository.getArticles(page).fold(
onSuccess = { articles ->
_uiState.value = ArticleUiState.Success(articles)
},
onFailure = { error ->
_uiState.value = ArticleUiState.Error(
message = error.message ?: "Something went wrong",
isAuthError = error is UnauthorizedException
)
}
)
}
}
fun retry() = loadArticles()
}
sealed class ArticleUiState {
object Loading : ArticleUiState()
data class Success(val articles: List<Article>) : ArticleUiState()
data class Error(
val message: String,
val isAuthError: Boolean = false
) : ArticleUiState()
}
Step 6 — Collect State in Fragment
class ArticleFragment : Fragment() {
private val viewModel: ArticleViewModel by viewModels()
private lateinit var adapter: ArticleAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupRecyclerView()
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
when (state) {
is ArticleUiState.Loading -> {
binding.progressBar.isVisible = true
binding.recyclerView.isVisible = false
binding.errorLayout.isVisible = false
}
is ArticleUiState.Success -> {
binding.progressBar.isVisible = false
binding.recyclerView.isVisible = true
binding.errorLayout.isVisible = false
adapter.submitList(state.articles)
}
is ArticleUiState.Error -> {
binding.progressBar.isVisible = false
binding.recyclerView.isVisible = false
binding.errorLayout.isVisible = true
binding.tvError.text = state.message
// Redirect to login if session expired
if (state.isAuthError) navigateToLogin()
}
}
}
}
}
binding.btnRetry.setOnClickListener { viewModel.retry() }
}
}
Best Practices
- Always wrap API calls in runCatching or try/catch — Retrofit with suspend functions throws exceptions on HTTP errors and network failures. Uncaught exceptions crash your app. The Repository is the right place to catch and map them.
- Use Dispatchers.IO in Repository — even though Retrofit's suspend functions are already safe to call on any thread, wrapping in
withContext(Dispatchers.IO)makes the intent explicit and ensures any blocking I/O around the call (DB writes, file operations) also runs off the main thread. - Disable logging interceptor in production —
HttpLoggingInterceptor.Level.BODYlogs full request and response bodies including auth tokens. Always gate it withBuildConfig.DEBUG. - Use sealed classes for UI state —
Loading,Success,Erroras a sealed class eliminates impossible states. You can't accidentally show a loading spinner and an error at the same time. - Make Repository injectable — pass the API interface as a constructor parameter to the Repository. This makes it trivially easy to inject a fake API in unit tests without hitting the real server.
- Use repeatOnLifecycle(STARTED) — when collecting flows in Fragments, always use
repeatOnLifecycle. Without it, the flow collects even when the app is in background, wasting resources and potentially crashing on view updates.
Frequently Asked Questions
How do I use Retrofit with Kotlin Coroutines?
Mark your Retrofit interface functions as suspend functions. Then call them from a coroutine — viewModelScope in a ViewModel or lifecycleScope in a Fragment. Retrofit handles threading automatically with suspend functions.
How do I handle errors in Retrofit with Coroutines?
Wrap calls in try/catch or runCatching in your Repository. Check for retrofit2.HttpException for HTTP errors, UnknownHostException for no internet, and SocketTimeoutException for timeouts. Map each to a meaningful type before returning to the ViewModel.
Should I use LiveData or StateFlow with Retrofit?
StateFlow is preferred for new projects — it's Kotlin-native, works naturally with coroutines, and supports repeatOnLifecycle. LiveData is still valid but StateFlow integrates more cleanly with the modern coroutines ecosystem. See our ViewModel State Management guide for a full comparison.
What is the difference between Retrofit and Volley?
Retrofit is type-safe, supports Kotlin Coroutines and suspend functions, and is actively maintained. Volley is Google's older callback-based HTTP library. For all new Android projects, Retrofit with Coroutines is the industry standard.
- Android WorkManager — Background Sync That Actually Works
- Kotlin Coroutines for Android — Suspend Functions, Scopes and Dispatchers
- ViewModel State Management — LiveData, StateFlow & SavedStateHandle
- Android ViewModel Example — Build Your First ViewModel
- RecyclerView Endless Scrolling with Retrofit and Coroutines
- Mark Retrofit interface functions as
suspend fun— Retrofit handles threading automatically - Wrap API calls in
runCatchingin the Repository — never let raw exceptions reach the ViewModel - Map HttpException codes (401, 404, 500) to meaningful error types
- Use sealed class UiState — Loading, Success, Error — eliminates impossible UI states
- Use
withContext(Dispatchers.IO)in Repository for any blocking I/O - Disable logging interceptor in production — always gate with BuildConfig.DEBUG
- Use
repeatOnLifecycle(STARTED)when collecting flows in Fragments