Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

Android Networking with Coroutines

October 7, 2025 · 9 min read

Android, Kotlin, Retrofit, Coroutines, Networking, Interview Prep

Part 4 of the Android Deep Dive series

Networking is a core competency for Android developers. Modern networking combines Retrofit for HTTP APIs with Kotlin Coroutines for async operations. In 2025-2026 interviews, expect questions about error handling, token refresh, and offline-first architecture.

2025-2026 Trend

Kotlinx Serialization has largely replaced Gson and Moshi for new projects. Its Kotlin-first design and KMP support make it the preferred choice. Interviewers may ask about trade-offs.

Retrofit Setup

// API Interface
interface NutritionApi {

    @GET("meals")
    suspend fun getMeals(
        @Query("date") date: String,
        @Query("userId") userId: String
    ): List<MealDto>

    @GET("meals/{id}")
    suspend fun getMeal(@Path("id") id: String): MealDto

    @POST("meals")
    suspend fun createMeal(@Body meal: CreateMealRequest): MealDto

    @PUT("meals/{id}")
    suspend fun updateMeal(
        @Path("id") id: String,
        @Body meal: UpdateMealRequest
    ): MealDto

    @DELETE("meals/{id}")
    suspend fun deleteMeal(@Path("id") id: String)

    @Multipart
    @POST("meals/analyze-image")
    suspend fun analyzeMealImage(
        @Part image: MultipartBody.Part
    ): MealAnalysisResponse
}

Hilt Module

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideJson(): Json = Json {
        ignoreUnknownKeys = true
        isLenient = true
        encodeDefaults = true
        coerceInputValues = true
    }

    @Provides
    @Singleton
    fun provideOkHttpClient(
        authInterceptor: AuthInterceptor,
        tokenAuthenticator: TokenAuthenticator
    ): OkHttpClient {
        return OkHttpClient.Builder()
            .addInterceptor(authInterceptor)
            .addInterceptor(HttpLoggingInterceptor().apply {
                level = if (BuildConfig.DEBUG) {
                    HttpLoggingInterceptor.Level.BODY
                } else {
                    HttpLoggingInterceptor.Level.NONE
                }
            })
            .authenticator(tokenAuthenticator)
            .connectTimeout(30, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .writeTimeout(30, TimeUnit.SECONDS)
            .build()
    }

    @Provides
    @Singleton
    fun provideRetrofit(
        okHttpClient: OkHttpClient,
        json: Json
    ): Retrofit {
        return Retrofit.Builder()
            .baseUrl(BuildConfig.API_BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
            .build()
    }

    @Provides
    @Singleton
    fun provideNutritionApi(retrofit: Retrofit): NutritionApi {
        return retrofit.create(NutritionApi::class.java)
    }
}

Authentication

Auth Interceptor

class AuthInterceptor @Inject constructor(
    private val tokenManager: TokenManager
) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val originalRequest = chain.request()

        // Skip auth for public endpoints
        if (originalRequest.url.encodedPath.contains("/public/")) {
            return chain.proceed(originalRequest)
        }

        val token = tokenManager.getAccessToken()

        val authenticatedRequest = originalRequest.newBuilder().apply {
            token?.let {
                header("Authorization", "Bearer $it")
            }
            header("X-Platform", "Android")
            header("X-App-Version", BuildConfig.VERSION_NAME)
        }.build()

        return chain.proceed(authenticatedRequest)
    }
}

Token Refresh with Authenticator

class TokenAuthenticator @Inject constructor(
    private val tokenManager: TokenManager,
    private val authRepository: Provider<AuthRepository>  // Provider to avoid circular DI
) : Authenticator {

    private val lock = Any()

    override fun authenticate(route: Route?, response: Response): Request? {
        // Only retry once
        if (response.request.header("X-Retry") != null) {
            return null
        }

        synchronized(lock) {
            val currentToken = tokenManager.getAccessToken()
            val requestToken = response.request.header("Authorization")
                ?.removePrefix("Bearer ")

            // Token was already refreshed by another thread
            if (currentToken != requestToken && currentToken != null) {
                return response.request.newBuilder()
                    .header("Authorization", "Bearer $currentToken")
                    .header("X-Retry", "true")
                    .build()
            }

            // Refresh token
            return runBlocking {
                try {
                    authRepository.get().refreshToken()
                    val newToken = tokenManager.getAccessToken()
                    response.request.newBuilder()
                        .header("Authorization", "Bearer $newToken")
                        .header("X-Retry", "true")
                        .build()
                } catch (e: Exception) {
                    tokenManager.clearTokens()
                    null  // Will trigger login
                }
            }
        }
    }
}

Interview Tip

The synchronized block in TokenAuthenticator prevents multiple threads from refreshing tokens simultaneously. This is a common interview topic - race conditions in token refresh.

Error Handling

Result Wrapper

sealed class Result<out T> {
    data class Success<T>(val data: T) : Result<T>()
    data class Error(val exception: AppException) : Result<Nothing>()
    data object Loading : Result<Nothing>()

    val isSuccess get() = this is Success
    val isError get() = this is Error

    fun getOrNull(): T? = (this as? Success)?.data

    inline fun <R> map(transform: (T) -> R): Result<R> = when (this) {
        is Success -> Success(transform(data))
        is Error -> this
        is Loading -> Loading
    }

    inline fun onSuccess(action: (T) -> Unit): Result<T> {
        if (this is Success) action(data)
        return this
    }

    inline fun onError(action: (AppException) -> Unit): Result<T> {
        if (this is Error) action(exception)
        return this
    }
}

// Safe API call wrapper
suspend fun <T> safeApiCall(apiCall: suspend () -> T): Result<T> {
    return try {
        Result.Success(apiCall())
    } catch (e: HttpException) {
        Result.Error(e.toAppException())
    } catch (e: IOException) {
        Result.Error(AppException.NetworkError("No internet connection"))
    } catch (e: Exception) {
        Result.Error(AppException.UnknownError(e.message ?: "Unknown error"))
    }
}

Custom Exceptions

sealed class AppException(message: String) : Exception(message) {
    class NetworkError(message: String) : AppException(message)
    class ServerError(val code: Int, message: String) : AppException(message)
    class AuthError(message: String) : AppException(message)
    class ValidationError(
        message: String,
        val errors: Map<String, String>? = null
    ) : AppException(message)
    class NotFoundError(message: String) : AppException(message)
    class UnknownError(message: String) : AppException(message)
}

fun HttpException.toAppException(): AppException {
    return when (code()) {
        401 -> AppException.AuthError("Session expired. Please log in again.")
        403 -> AppException.AuthError("You don't have permission for this action.")
        404 -> AppException.NotFoundError("Resource not found.")
        422 -> {
            val errors = parseValidationErrors()
            AppException.ValidationError("Validation failed", errors)
        }
        in 500..599 -> AppException.ServerError(code(), "Server error. Please try again.")
        else -> AppException.UnknownError("Request failed: ${message()}")
    }
}

Repository Pattern

class MealRepository @Inject constructor(
    private val api: NutritionApi,
    private val mealDao: MealDao,
    @IoDispatcher private val dispatcher: CoroutineDispatcher
) {
    // Observe local data with Flow
    fun observeMeals(date: LocalDate): Flow<List<Meal>> {
        return mealDao.observeMealsByDate(date)
            .map { entities -> entities.map { it.toDomain() } }
            .flowOn(dispatcher)
    }

    // Sync with server
    suspend fun syncMeals(date: LocalDate): Result<Unit> = withContext(dispatcher) {
        safeApiCall {
            val serverMeals = api.getMeals(
                date = date.toString(),
                userId = getCurrentUserId()
            )
            mealDao.insertAll(serverMeals.map { it.toEntity() })
        }
    }

    // Local-first create
    suspend fun createMeal(meal: Meal): Result<Meal> = withContext(dispatcher) {
        // Save locally first
        val entity = meal.toEntity().copy(syncStatus = SyncStatus.PENDING)
        mealDao.insert(entity)

        // Sync with server
        safeApiCall {
            val serverMeal = api.createMeal(meal.toCreateRequest())
            mealDao.updateSyncStatus(meal.id, SyncStatus.SYNCED, serverMeal.id)
            serverMeal.toDomain()
        }
    }

    // Offline-aware delete
    suspend fun deleteMeal(meal: Meal): Result<Unit> = withContext(dispatcher) {
        // Mark as deleted locally
        mealDao.markDeleted(meal.id)

        // Sync deletion with server
        safeApiCall {
            api.deleteMeal(meal.id)
            mealDao.permanentlyDelete(meal.id)
        }
    }
}

Common Mistake

Never call API methods directly from the main thread. Always use viewModelScope or withContext(Dispatchers.IO). Retrofit suspend functions handle the threading, but you should still be explicit.

Retry with Exponential Backoff

suspend fun <T> retryWithExponentialBackoff(
    times: Int = 3,
    initialDelay: Long = 100,
    maxDelay: Long = 5000,
    factor: Double = 2.0,
    block: suspend () -> T
): T {
    var currentDelay = initialDelay
    repeat(times - 1) { attempt ->
        try {
            return block()
        } catch (e: IOException) {
            // Only retry on network errors
            delay(currentDelay)
            currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay)
        }
    }
    return block()  // Last attempt
}

// Usage
suspend fun fetchWithRetry() {
    val result = retryWithExponentialBackoff {
        api.getMeals(date, userId)
    }
}

Interview Questions

Q: How do you handle API errors gracefully?

Use a sealed class hierarchy with a safe wrapper:

// 1. Define exception types
sealed class AppException : Exception() {
    class Network : AppException()
    class Server(val code: Int) : AppException()
    class Auth : AppException()
}

// 2. Wrap API calls
suspend fun <T> safeApiCall(call: suspend () -> T): Result<T> {
    return try {
        Result.Success(call())
    } catch (e: HttpException) {
        Result.Error(mapToAppException(e))
    } catch (e: IOException) {
        Result.Error(AppException.Network())
    }
}

// 3. Handle in ViewModel
viewModelScope.launch {
    repository.getData()
        .onSuccess { data -> _state.value = Success(data) }
        .onError { error ->
            _state.value = Error(getErrorMessage(error))
        }
}

Q: How does Retrofit work with Coroutines?

Retrofit 2.6+ natively supports suspend functions:

// Declare suspend function in interface
interface Api {
    @GET("users/{id}")
    suspend fun getUser(@Path("id") id: String): User
}

// Retrofit creates coroutine-based implementation
// - Automatically switches to IO dispatcher
// - Throws HttpException for error responses
// - Throws IOException for network failures

// Usage
viewModelScope.launch {
    try {
        val user = api.getUser("123")
    } catch (e: HttpException) {
        // Handle HTTP error
    } catch (e: IOException) {
        // Handle network error
    }
}

Q: How do you implement token refresh without user disruption?

Use OkHttp's Authenticator interface:

class TokenAuthenticator : Authenticator {
    override fun authenticate(route: Route?, response: Response): Request? {
        // 1. Check if already retried
        if (response.request.header("X-Retry") != null) return null

        // 2. Synchronize to prevent race conditions
        synchronized(this) {
            // 3. Refresh token
            val newToken = runBlocking { refreshToken() }

            // 4. Retry with new token
            return response.request.newBuilder()
                .header("Authorization", "Bearer $newToken")
                .header("X-Retry", "true")
                .build()
        }
    }
}

Key points:

  • Authenticator only called on 401 responses
  • Synchronized block prevents duplicate refreshes
  • X-Retry header prevents infinite loops
  • Returns null to abort if refresh fails

Q: What's the difference between Interceptor and Authenticator?

AspectInterceptorAuthenticator
When calledEvery requestOnly on 401
PurposeAdd headers, log, modifyHandle auth failures
ReturnModified request/responseRetry request or null

Common Mistakes

1. Ignoring Cancellation

// BAD - Swallows cancellation
suspend fun fetchData() {
    try {
        api.getData()
    } catch (e: Exception) {
        // CancellationException caught, coroutine won't cancel!
    }
}

// GOOD - Rethrow CancellationException
suspend fun fetchData() {
    try {
        api.getData()
    } catch (e: CancellationException) {
        throw e  // Let cancellation propagate
    } catch (e: Exception) {
        handleError(e)
    }
}

2. Memory Leaks with Image Uploads

// BAD - Large bitmap stays in memory
val bitmap = BitmapFactory.decodeFile(path)
uploadBitmap(bitmap)  // Bitmap not recycled

// GOOD - Clean up resources
val bitmap = BitmapFactory.decodeFile(path)
try {
    uploadBitmap(bitmap)
} finally {
    bitmap.recycle()
}

// BETTER - Stream directly
val file = File(path)
val requestBody = file.asRequestBody("image/jpeg".toMediaType())
uploadFile(requestBody)

3. Not Handling Offline State

// BAD - Crashes when offline
suspend fun getData() = api.getData()

// GOOD - Fallback to cache
suspend fun getData(): List<Data> {
    return try {
        val remote = api.getData()
        cache.save(remote)
        remote
    } catch (e: IOException) {
        cache.get() ?: throw e
    }
}

4. Blocking on Main Thread

// BAD - runBlocking on Main thread
fun onClick() {
    runBlocking {
        api.postData()  // Blocks UI!
    }
}

// GOOD - Use viewModelScope
fun onClick() {
    viewModelScope.launch {
        api.postData()
    }
}

Summary Table

ConceptPurposeKey Points
suspend fun in RetrofitAsync API callsThrows on error, no callback
InterceptorModify all requestsAdd headers, log, transform
AuthenticatorHandle 401 errorsToken refresh, retry
Result wrapperExplicit error handlingSuccess/Error/Loading states
safeApiCallCatch exceptionsConvert to domain errors
Repository patternAbstract data sourceLocal-first, sync to server
Exponential backoffRetry on failurePrevent server overload

Next in series: Part 5 - Data Persistence with Room covers Room database, migrations, and offline-first architecture.

← Previous

Android Data Persistence with Room

Next →

Android Navigation and Architecture Patterns