Retrofit with Kotlin Coroutines in Android - The Complete Guide

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.

Retrofit with Kotlin Coroutines in Android

Retrofit with Kotlin Coroutines in Android

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 productionHttpLoggingInterceptor.Level.BODY logs full request and response bodies including auth tokens. Always gate it with BuildConfig.DEBUG.
  • Use sealed classes for UI stateLoading, Success, Error as 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.

📝 Summary
  • Mark Retrofit interface functions as suspend fun — Retrofit handles threading automatically
  • Wrap API calls in runCatching in 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

Pragnesh Ghoda

A forward-thinking developer offering more than 8 years of experience building, integrating, and supporting android applications for mobile and tablet devices on the Android platform. Talks about #kotlin and #android

Post a Comment

Please let us know about any concerns or query.

Previous Post Next Post

Contact Form