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?
| Aspect | Interceptor | Authenticator |
|---|---|---|
| When called | Every request | Only on 401 |
| Purpose | Add headers, log, modify | Handle auth failures |
| Return | Modified request/response | Retry 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
| Concept | Purpose | Key Points |
|---|---|---|
suspend fun in Retrofit | Async API calls | Throws on error, no callback |
Interceptor | Modify all requests | Add headers, log, transform |
Authenticator | Handle 401 errors | Token refresh, retry |
Result wrapper | Explicit error handling | Success/Error/Loading states |
safeApiCall | Catch exceptions | Convert to domain errors |
| Repository pattern | Abstract data source | Local-first, sync to server |
| Exponential backoff | Retry on failure | Prevent server overload |
Next in series: Part 5 - Data Persistence with Room covers Room database, migrations, and offline-first architecture.