Step 9: Networking with Retrofit in Android — Fetch Live Data with Kotlin Coroutines

📚 Learn Android with Kotlin — Series
1. Kotlin 2. Android Studio 3. Activities 4. Intents 5. Fragments 6. RecyclerView 7. LiveData 8. Room 9. Retrofit 10. Material 🔒 11. Firebase 🔒 12. Testing 🔒 13. Publish 🔒

In Step 8 you learned how Room Database stores data permanently on the device. But that data still lives only locally — your app can't talk to the outside world yet. Most real apps need to fetch live data from a server: news articles, user profiles, product listings, weather. That's networking.

Retrofit is the industry-standard HTTP client for Android, and it pairs perfectly with Kotlin Coroutines. You define your API endpoints as simple Kotlin interface functions with suspend, and Retrofit handles all the threading, JSON parsing, and HTTP mechanics automatically. No callbacks, no thread management, no boilerplate — just clean, readable network calls.

Android Retrofit with Kotlin Coroutines — networking made simple

Retrofit + Coroutines — the modern standard for Android networking

Prerequisites: You should be comfortable with ViewModel and LiveData (Step 7), Room Database (Step 8), and Kotlin Coroutines before starting this guide.

Why Retrofit?

Android provides raw HttpURLConnection and OkHttp for HTTP requests. You could use them directly — but you'd spend most of your time writing JSON parsing code, thread management, and error handling rather than building features. Retrofit abstracts all of that:

Retrofit + Coroutines Raw OkHttp / HttpURLConnection
API definition ✅ Simple interface with annotations ❌ Manual URL building and parsing
JSON parsing ✅ Automatic via Gson/Moshi converters ❌ Manual deserialization
Threading ✅ Automatic with suspend functions ❌ Manual background thread management
Error handling ✅ Typed exceptions via try/catch ❌ Manual status code checking
Industry adoption ✅ Standard in almost every Android codebase ❌ Only for very specific low-level cases

1. Add Dependencies

// build.gradle.kts (app)
dependencies {
    // Retrofit — HTTP client
    implementation("com.squareup.retrofit2:retrofit:2.11.0")

    // Gson converter — maps JSON to Kotlin data classes automatically
    implementation("com.squareup.retrofit2:converter-gson:2.11.0")

    // OkHttp logging — logs full request/response in Logcat (dev only)
    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"/>

2. Define the Data Model

Retrofit maps JSON responses directly to Kotlin data classes using Gson. Field names in the data class must match the JSON keys, or use @SerializedName to map different names:

import com.google.gson.annotations.SerializedName

// Maps to a JSON object like:
// { "id": 1, "title": "...", "body": "...", "user_id": 5 }
data class Article(
    val id: Int,
    val title: String,
    val body: String,
    @SerializedName("user_id") val userId: Int,       // JSON key differs from property name
    @SerializedName("created_at") val createdAt: String
)

// For paginated list responses
data class ArticleResponse(
    val articles: List<Article>,
    val total: Int,
    val page: Int
)

3. Define the API Interface

This is where Retrofit shines. Each endpoint becomes a single suspend fun in an interface. Retrofit generates the full implementation — you never write an HTTP call manually:

import retrofit2.http.*

interface ArticleApi {

    // GET — fetch a list, with optional query parameters
    @GET("articles")
    suspend fun getArticles(
        @Query("page") page: Int = 1,
        @Query("limit") limit: Int = 20
    ): List<Article>

    // GET with a path parameter — e.g. /articles/42
    @GET("articles/{id}")
    suspend fun getArticleById(
        @Path("id") id: Int
    ): Article

    // POST — send a request body
    @POST("articles")
    suspend fun createArticle(
        @Body article: Article
    ): Article

    // PUT — update a resource
    @PUT("articles/{id}")
    suspend fun updateArticle(
        @Path("id") id: Int,
        @Body article: Article
    ): Article

    // DELETE
    @DELETE("articles/{id}")
    suspend fun deleteArticle(
        @Path("id") id: Int
    )

    // With Authorization header
    @GET("user/profile")
    suspend fun getProfile(
        @Header("Authorization") token: String
    ): UserProfile
}

4. Build the Retrofit Instance

Create Retrofit as a singleton — one instance shared across your entire app. Use an OkHttp interceptor to add auth headers to every request automatically:

import okhttp3.Interceptor
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.example.com/v1/"

    // Auth interceptor — adds Bearer token to every request automatically
    private val authInterceptor = Interceptor { chain ->
        val request = chain.request().newBuilder()
            .addHeader("Authorization", "Bearer ${TokenManager.getToken()}")
            .addHeader("Accept", "application/json")
            .build()
        chain.proceed(request)
    }

    // Logging interceptor — only in debug builds, never in production
    private val loggingInterceptor = HttpLoggingInterceptor().apply {
        level = if (BuildConfig.DEBUG) {
            HttpLoggingInterceptor.Level.BODY    // logs full request + response
        } else {
            HttpLoggingInterceptor.Level.NONE    // no logs in production
        }
    }

    private val okHttpClient = OkHttpClient.Builder()
        .addInterceptor(authInterceptor)
        .addInterceptor(loggingInterceptor)
        .connectTimeout(30, TimeUnit.SECONDS)
        .readTimeout(30, TimeUnit.SECONDS)
        .writeTimeout(30, TimeUnit.SECONDS)
        .build()

    val articleApi: ArticleApi = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .client(okHttpClient)
        .addConverterFactory(GsonConverterFactory.create())
        .build()
        .create(ArticleApi::class.java)
}

5. Repository — Wrap API Calls with Error Handling

The Repository wraps Retrofit calls and maps raw exceptions into typed errors — so the ViewModel always receives either a clean success or a meaningful error, never a raw network exception. This is also where you'd add Room caching for offline-first behaviour:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class ArticleRepository(
    private val api: ArticleApi = RetrofitClient.articleApi,
    private val articleDao: ArticleDao? = null  // optional Room DAO for caching
) {

    suspend fun getArticles(page: Int = 1): Result<List<Article>> =
        withContext(Dispatchers.IO) {
            runCatching {
                val articles = api.getArticles(page = page)
                // Cache to Room if DAO is provided (offline-first pattern)
                articleDao?.insertAll(articles)
                articles
            }.recoverCatching { throwable ->
                // Network failed — try returning cached data from Room
                val cached = articleDao?.getAllArticles()
                if (!cached.isNullOrEmpty()) cached
                else throw mapNetworkError(throwable)
            }
        }

    suspend fun getArticleById(id: Int): Result<Article> =
        withContext(Dispatchers.IO) {
            runCatching { api.getArticleById(id) }
                .mapFailure { mapNetworkError(it) }
        }

    private fun mapNetworkError(throwable: Throwable): Exception = when (throwable) {
        is retrofit2.HttpException -> when (throwable.code()) {
            401 -> UnauthorizedException("Session expired. Please log in again.")
            403 -> ForbiddenException("You don't have permission to access this.")
            404 -> NotFoundException("Content not found.")
            in 500..599 -> ServerException("Server error. Please try again later.")
            else -> ApiException("Request failed: HTTP ${throwable.code()}")
        }
        is java.net.UnknownHostException -> NetworkException("No internet connection.")
        is java.net.SocketTimeoutException -> TimeoutException("Request timed out.")
        else -> UnknownException(throwable.message ?: "Unknown error occurred.")
    }
}

// Typed error classes — each can be handled differently in the UI
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)

// Extension to map failure type
fun <T> Result<T>.mapFailure(transform: (Throwable) -> Throwable): Result<T> =
    exceptionOrNull()?.let { Result.failure(transform(it)) } ?: this

6. ViewModel — Sealed UiState Pattern

Model UI state as a sealed class — Loading, Success, Error. This makes impossible states literally unrepresentable: you can never be in Loading and Error simultaneously, because they're different sealed class instances:

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

sealed class ArticleUiState {
    object Loading : ArticleUiState()
    data class Success(val articles: List<Article>) : ArticleUiState()
    data class Error(
        val message: String,
        val isAuthError: Boolean = false,
        val isNetworkError: Boolean = false
    ) : ArticleUiState()
}

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,
                        isNetworkError = error is NetworkException
                    )
                }
            )
        }
    }

    fun retry() = loadArticles()
}

7. Collect State in Fragment

Use repeatOnLifecycle(STARTED) when collecting flows in Fragments — it pauses collection when the app goes to background, preventing wasted work and potential crashes on stale views:

class ArticleFragment : Fragment() {

    private var _binding: FragmentArticleBinding? = null
    private val binding get() = _binding!!
    private val viewModel: ArticleViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // Handle retry button
        binding.btnRetry.setOnClickListener { viewModel.retry() }

        // Collect state — always repeatOnLifecycle in Fragments
        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.layoutError.isVisible = false
                        }
                        is ArticleUiState.Success -> {
                            binding.progressBar.isVisible = false
                            binding.layoutError.isVisible = false
                            binding.recyclerView.isVisible = true
                            adapter.submitList(state.articles)
                        }
                        is ArticleUiState.Error -> {
                            binding.progressBar.isVisible = false
                            binding.recyclerView.isVisible = false
                            binding.layoutError.isVisible = true
                            binding.tvError.text = state.message
                            // Special handling for auth errors
                            if (state.isAuthError) navigateToLogin()
                        }
                    }
                }
            }
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null  // prevent memory leak — same as Step 5
    }
}

8. Offline-First with Room + Retrofit

The real power of combining Step 8 and Step 9 is offline-first architecture. The app loads cached data from Room instantly, then refreshes from the network in the background. Users see content immediately — even without internet:

class ArticleRepository(
    private val api: ArticleApi,
    private val dao: ArticleDao
) {
    // Room Flow — emits immediately with cached data, then updates when network responds
    val articles: Flow<List<Article>> = dao.getAllArticles()

    // Call this to refresh from network — Room Flow updates automatically
    suspend fun refresh(): Result<Unit> = withContext(Dispatchers.IO) {
        runCatching {
            val fresh = api.getArticles()
            dao.deleteAll()
            dao.insertAll(fresh)  // Room Flow re-emits with new data
        }.mapFailure { mapNetworkError(it) }
    }
}

// In ViewModel
class ArticleViewModel(private val repository: ArticleRepository) : ViewModel() {

    // Always show cached data first — instant display
    val articles: StateFlow<List<Article>> = repository.articles
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    private val _isRefreshing = MutableStateFlow(false)
    val isRefreshing: StateFlow<Boolean> = _isRefreshing

    private val _error = MutableStateFlow<String?>(null)
    val error: StateFlow<String?> = _error

    init { refresh() }

    fun refresh() {
        viewModelScope.launch {
            _isRefreshing.value = true
            repository.refresh().onFailure { _error.value = it.message }
            _isRefreshing.value = false
        }
    }
}

Best Practices

  • Always wrap API calls in runCatching — Retrofit with suspend functions throws exceptions on HTTP errors and network failures. Uncaught exceptions in viewModelScope.launch crash your app. The Repository is the right place to catch and map them into typed errors.
  • Gate logging interceptor with BuildConfig.DEBUGHttpLoggingInterceptor.Level.BODY logs full request and response bodies including auth tokens. Never ship this to production. Always wrap it in a debug check.
  • Use sealed classes for UI stateLoading, Success, Error as a sealed class eliminates impossible states. The when expression is exhaustive — if you add a new state, the compiler forces you to handle it everywhere.
  • One Retrofit instance — just like Room's singleton pattern, create Retrofit once using object RetrofitClient. Multiple instances waste memory and can cause authentication issues with shared interceptors.
  • Use repeatOnLifecycle(STARTED) in Fragments — without it, the flow collects even in the background. Combine with Fragment ViewBinding cleanup in onDestroyView() for fully leak-free UI.
  • Combine with Room for offline-first — network-only apps show a loading spinner every time and fail completely offline. Caching responses in Room means users see content immediately and the network refresh happens silently in the background.

Try It Yourself

  1. Fetch from a public API — use the free JSONPlaceholder API (https://jsonplaceholder.typicode.com). Define a data class for Post, create an interface with @GET("posts"), build a Retrofit instance, and display the results in a RecyclerView. No API key needed.
  2. Handle all three states — add a loading spinner that shows during fetch, a list that shows on success, and a visible error layout with a "Retry" button on failure. Test the error state by turning off WiFi before hitting retry.
  3. Add offline caching — add a Room DAO for the Post entity. In your Repository, cache successful responses to Room and fall back to cached data on network failure. Kill WiFi, close and reopen the app — the cached posts should appear immediately.

Frequently Asked Questions

How do I use Retrofit with Kotlin Coroutines?
Mark your Retrofit interface functions as suspend. Call them from viewModelScope or lifecycleScope. Retrofit handles threading automatically — network runs off the main thread. Wrap in runCatching in the Repository to handle errors. See the full pattern with Kotlin Coroutines.

How do I handle HTTP errors in Retrofit with Coroutines?
Retrofit throws retrofit2.HttpException for non-2xx responses — check throwable.code() for 401, 404, 500 etc. Also catch UnknownHostException for no internet and SocketTimeoutException for timeouts. Map each to a typed class in the Repository before the ViewModel sees it.

What is the difference between Retrofit and Volley?
Retrofit defines endpoints as Kotlin interface functions with annotations, is type-safe, supports Coroutines natively, and is the current industry standard. Volley uses callbacks, has no Coroutines support, and is an older library. Use Retrofit for all new Android projects.

How do I implement offline-first with Retrofit and Room?
Use Room as the single source of truth. The ViewModel observes a Flow from Room — instant display from cache. A refresh() function fetches from Retrofit and writes to Room. The Room Flow re-emits automatically. If network fails, cached data stays visible.

📚 Continue the Series
📝 Step 9 Summary
  • Mark Retrofit interface functions as suspend fun — threading handled automatically
  • Use annotations@GET, @POST, @Path, @Query, @Body — to define endpoints
  • Build Retrofit as a singleton with an OkHttp client — one instance for the whole app
  • Add auth headers via OkHttp Interceptor — applied to every request automatically
  • Gate logging interceptor with BuildConfig.DEBUG — never log tokens in production
  • Repository wraps API calls — catches exceptions, maps to typed errors, returns Result<T>
  • Use sealed class UiState (Loading / Success / Error) — eliminates impossible UI states
  • Use repeatOnLifecycle(STARTED) in Fragments — lifecycle-safe flow collection
  • Combine with Room for offline-first — cache responses, display instantly, refresh in background

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