Android Networking
Irbisa · cheatsheetSeptember 13, 2026

Android Networking

Retrofit, OkHttp, interceptors, kotlinx.serialization, error handling

Middle Developer20 itemscompressed for a skim
  1. 01

    What are Retrofit and OkHttp? How do they relate?

    Easy

    OkHttp is the client that actually moves the bytes, and Retrofit is a typed API layer generated on top of it.

    @Serializable data class UserDto(val id: Long, val name: String)
    
    interface UserApi {
      @GET("users/{id}")
      suspend fun getUser(@Path("id") id: Long): UserDto
    }
    …
  2. 02

    Application vs network interceptors in OkHttp — what is the difference, and what belongs in each?

    Medium

    An OkHttp Interceptor takes a Chain and returns a Response, so it can observe, rewrite, retry or short-circuit any call.

    class AuthInterceptor(private val tokens: TokenStore) : Interceptor {
      override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request().newBuilder()
          .addHeader("Authorization", "Bearer ${tokens.access.orEmpty()}")
          .build()
        return chain.proceed(request)
    …
  3. 03

    How do you handle errors and typed responses with Retrofit?

    Medium

    Retrofit throws on a non-2xx response when a suspend function returns the body type directly, so something has to catch.

    sealed class AppError(message: String? = null) : Throwable(message) {
      object NetworkUnavailable : AppError()
      data class Server(val code: Int, val payload: String?) : AppError("HTTP $code")
      data class NotAuthorized(override val cause: Throwable? = null) : AppError("401")
      data class Contract(override val cause: Throwable) : AppError(cause.message)
      data class Unknown(override val cause: Throwable) : AppError(cause.message)
    …
  4. 04

    How do you cache HTTP responses on Android with OkHttp?

    Medium

    OkHttp ships a built-in HTTP cache that respects standard Cache-Control headers.

    val cacheDir = File(context.cacheDir, "http").apply { mkdirs() }
    val cache = Cache(directory = cacheDir, maxSize = 50L * 1024 * 1024)   // 50 MB
    
    // Servers don't always send a useful Cache-Control — rewrite it on the way in
    val rewriteCache = Interceptor { chain ->
      chain.proceed(chain.request()).newBuilder()
    …
  5. 05

    How do you upload a file with Retrofit, and how do you report progress?

    Medium

    Retrofit sends a multipart body when you mark the function @Multipart and pass each field as a @Part.

    interface UploadApi {
      @Multipart
      @POST("upload")
      suspend fun upload(
        @Part("name") name: RequestBody,
        @Part file: MultipartBody.Part
    …
  6. 06

    Which real-time transport do you pick on Android — WebSocket, SSE or long-poll?

    Medium

    WebSocket is full duplex, SSE is one-way from server to client, and long-poll is the fallback that costs the most battery.

    // Socket as a Flow: opened on collect, closed when the collector goes away
    fun OkHttpClient.messages(url: String): Flow<String> = callbackFlow {
      val listener = object : WebSocketListener() {
        override fun onMessage(ws: WebSocket, text: String) { trySend(text) }
        override fun onClosed(ws: WebSocket, code: Int, reason: String) { close() }
        override fun onFailure(ws: WebSocket, t: Throwable, r: Response?) { close(t) }
    …
  7. 07

    How do you configure kotlinx.serialization for an API you do not control?

    Medium

    Real APIs send fields you never modelled, omit ones you expect and change shape without telling you, so the Json instance has to be configured for tolerance instead of left on its strict defaults.

    val json = Json {
      ignoreUnknownKeys = true       // the backend WILL add fields
      explicitNulls = false          // a missing key decodes to null or the default
      coerceInputValues = true       // a null into a non-null property with a default
    }
    …
  8. 08

    Which timeouts does OkHttp give you, and what does it already retry on its own?

    Medium

    OkHttp has four timeouts covering different stages of a call, and the only thing it retries by itself is a connection that failed before the server ever saw your request.

    val client = OkHttpClient.Builder()
      .connectTimeout(10, TimeUnit.SECONDS)
      .readTimeout(15, TimeUnit.SECONDS)     // inactivity between bytes, not a total budget
      .writeTimeout(15, TimeUnit.SECONDS)
      .callTimeout(30, TimeUnit.SECONDS)     // the only one that bounds the whole call
      .retryOnConnectionFailure(true)        // default: another route, or a fresh connection
    …
  9. 09

    How do you test the networking layer without talking to a real server?

    Medium

    Point Retrofit at a MockWebServer on localhost and assert both halves — the request your client actually sent and what your code did with the reply.

    class UserApiTest {
      private val server = MockWebServer()
      private lateinit var api: UserApi
    
      @BeforeTest fun setUp() {
        server.start()
    …
  10. 10

    How does Paging 3 work, and where does RemoteMediator fit when the list must also work offline?

    Hard

    Paging 3 streams a PagingData of items produced by a PagingSource, and RemoteMediator is the piece that refills a local database so the same list keeps working offline.

    // Network-only source: one page plus the keys either side
    class PostsPagingSource(private val api: PostsApi) : PagingSource<String, Post>() {
      override suspend fun load(params: LoadParams<String>): LoadResult<String, Post> = try {
        val page = api.posts(cursor = params.key, limit = params.loadSize)
        LoadResult.Page(data = page.items, prevKey = null, nextKey = page.nextCursor)
      } catch (e: IOException) {
    …
  11. 11

    A list of remote images scrolls badly and memory climbs as the user goes down it. What is an image loader doing for you, and what do you still have to get right?

    Medium

    An image loader exists to decode at the size you actually display, cache the result in memory and on disk, and cancel a request the moment its target goes away.

    @Composable
    fun Avatar(url: String, modifier: Modifier = Modifier) {
      AsyncImage(
        model = ImageRequest.Builder(LocalContext.current)
          .data(url)
          .crossfade(true)
    …
  12. 12

    How does the app know it is offline, and why is checking connectivity before every request the wrong instinct?

    Easy

    Connectivity is observed through a ConnectivityManager network callback, but a request is never gated on it — a check can only tell you the call would probably have failed anyway.

    fun ConnectivityManager.statusFlow(): Flow<NetworkStatus> = callbackFlow {
      val callback = object : ConnectivityManager.NetworkCallback() {
        override fun onAvailable(network: Network) { trySend(NetworkStatus.Available) }
        override fun onLost(network: Network) { trySend(NetworkStatus.Lost) }
        override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
          val validated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
    …
  13. 13

    A reviewer asks why every repository call is wrapped in withContext(Dispatchers.IO) — does a Retrofit suspend function need it?

    Easy

    No — a Retrofit suspend function never blocks the calling thread, so wrapping it in withContext(Dispatchers.IO) buys nothing but an extra thread hop.

    interface UserApi {
      @GET("users/{id}")
      suspend fun getUser(@Path("id") id: Long): UserDto        // suspends, never blocks
    
      @GET("users/{id}")
      fun getUserCall(@Path("id") id: Long): Call<UserDto>      // blocking on execute()
    …
  14. 14

    The logging interceptor never prints the Authorization header, and QA then finds access tokens in a release bug report — what went wrong on both counts?

    Easy

    Both are the same interceptor misconfigured: it was added before the interceptor that attaches the header, and it was left above Level.NONE in the release build.

    val logging = HttpLoggingInterceptor().apply {
      level = if (BuildConfig.DEBUG) Level.BODY else Level.NONE   // never BODY in release
      redactHeader("Authorization")
      redactHeader("Cookie")
    }
    …
  15. 15

    The user backs out of a screen while a request is in flight — what happens to the call, and why does an error toast still pop up sometimes?

    Medium

    viewModelScope is cancelled in onCleared, Retrofit cancels the underlying OkHttp Call, and the toast appears because somebody caught the CancellationException and rendered it as a failure.

    class UserViewModel(private val repo: UserRepository) : ViewModel() {
    
      // ❌ CancellationException lands in the generic catch: an error state for a dead screen
      fun loadWrong(id: Long) = viewModelScope.launch {
        try { _state.value = Loaded(repo.user(id)) }
        catch (e: Exception) { _state.value = Failed(e.message) }
    …
  16. 16

    Someone adds an interceptor that logs the response body, and now every call fails to parse with IllegalStateException: closed — what is a ResponseBody really?

    Medium

    A ResponseBody is a one-shot stream over the still-open socket, not a value: reading it consumes it, string() also closes it, and whoever comes next gets a closed stream.

    // ❌ consumes the body; Retrofit's converter then reads a closed stream
    val logBody = Interceptor { chain ->
      val response = chain.proceed(chain.request())
      Log.d("api", response.body!!.string())          // downstream: IllegalStateException: closed
      response
    }
    …
  17. 17

    The base URL is https://api.example.com/v2/ but @GET("/users") hits /users with no /v2 in it — how does Retrofit build the final URL?

    Medium

    Retrofit resolves the annotation value against baseUrl exactly the way a browser resolves an href, so a leading slash means "from the root of the host" and discards the base path.

    val retrofit = Retrofit.Builder()
      .baseUrl("https://api.example.com/v2/")     // must end in "/"
      .client(client)
      .build()
    
    interface Api {
    …
  18. 18

    You never wrote a class implementing your API interface, so what does retrofit.create(Api::class.java) hand back, and when is a typo in an annotation found?

    Medium

    It hands back a java.lang.reflect.Proxy implementing your interface, and by default a bad annotation blows up on the first call to that method rather than at create().

    val retrofit = Retrofit.Builder()
      .baseUrl("https://api.example.com/")
      .client(client)
      // factories are asked in order, first answer wins: Scalars before Json, or a
      // String return type gets handed to the JSON parser
      .addConverterFactory(ScalarsConverterFactory.create())
    …
  19. 19

    A grid fires forty image requests at one host and a trace shows only five running, and on another screen every call stalls behind a live stream — what is going on?

    Hard

    OkHttp's Dispatcher caps asynchronous calls at 64 in flight and 5 per host, and a long-lived streaming call holds one of those five host slots for as long as it lives.

    // Defaults: 64 in flight, 5 per host, applied to enqueue() — which is what every
    // Retrofit suspend function uses. Blocking execute() calls are counted, not throttled.
    val dispatcher = Dispatcher().apply {
      maxRequests = 64
      maxRequestsPerHost = 15        // HTTP/2 multiplexes them onto one connection
    }
    …
  20. 20

    A 400 MB download OOMs on some phones and starts from zero every time the connection drops — how do you make it stream to disk and resume?

    Hard

    @Streaming is what stops Retrofit buffering the body into memory, and a Range request measured against the bytes already on disk is what makes it resume.

    interface FilesApi {
      @Streaming                                  // no buffering: you get the live stream
      @GET("files/{name}")
      suspend fun download(
        @Path("name") name: String,
        @Header("Range") range: String?,
    …