🌐 Core Concept · ~40 min read · Intermediate → Advanced

OkHttp & Networking

How Android apps talk to the world — OkHttp’s interceptor chain, connection pooling, caching, Retrofit integration, token refresh, certificate pinning, WebSockets, and every networking pattern interviewers probe for.

OkHttp architecture

OkHttp is the HTTP client that powers almost every Android app. Retrofit sits on top of it, Coil and Picasso use it under the hood, and even the Android system’s HttpURLConnection was updated to use it internally. Understanding OkHttp at the architecture level — not just how to copy-paste a Retrofit.Builder — is what separates senior candidates from junior ones in networking interviews.

Every request flows through a pipeline of interceptors arranged in a chain. This is OkHttp’s core design: interceptors are the extension point for everything — logging, authentication, caching, retry logic, metrics. You add interceptors to the chain; OkHttp calls them in order on the way out and in reverse on the way back.

📋 OkHttp request pipeline — interceptor chain in order
Your App Code Application Interceptors Auth header, logging, retry logic — see original request + final response addInterceptor() RetryAndFollowUp Interceptor Bridge Interceptor (headers, cookies) Cache Interceptor (serve from cache?) Network Interceptors See actual network call, compressed body, real headers addNetworkInterceptor() 🌐 Server / Network Response travels back UP the chain
OkHttpClient — the singleton you build once and reuse everywhere
// OkHttpClient is heavyweight — it owns thread pools and connection pools // Create ONCE (singleton via Hilt @Singleton) and share across the app val okHttpClient = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) // time to establish TCP connection .readTimeout(30, TimeUnit.SECONDS) // time to read server response .writeTimeout(30, TimeUnit.SECONDS) // time to send request body .connectionPool(ConnectionPool(5, 5, TimeUnit.MINUTES)) // 5 idle connections, 5min keep-alive .addInterceptor(authInterceptor) // application interceptor — runs before cache .addInterceptor(loggingInterceptor) .addNetworkInterceptor(metricsInterceptor) // network interceptor — sees real wire bytes .cache(Cache(File(cacheDir, "http_cache"), 10L * 1024 * 1024)) // 10MB cache .build() // Direct OkHttp request (without Retrofit) val request = Request.Builder() .url("https://api.example.com/products") .header("Accept", "application/json") .get() .build() // Execute asynchronously okHttpClient.newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { /* network error */ } override fun onResponse(call: Call, response: Response) { response.use { // use{} closes body automatically if (!response.isSuccessful) throw IOException("HTTP ${response.code}") val body = response.body?.string() } } })

Interceptors

Interceptors are OkHttp’s most powerful feature. Every interceptor receives a Chain, can inspect and modify the request before passing it along, calls chain.proceed(request) to let the rest of the chain run, then inspects and modifies the response on the way back. This makes interceptors perfect for cross-cutting concerns — authentication, logging, retry logic, timing, and response transformation — without cluttering your API call code.

Application vs network interceptors

FeatureApplication InterceptorNetwork Interceptor
Added viaaddInterceptor()addNetworkInterceptor()
Runs whenAlways — even for cached responsesOnly on actual network calls
SeesOriginal request, final responseActual wire request, compressed response
Can short-circuitYes — return without calling proceed()Yes, but only after connection is established
Best forAuth headers, logging (human-readable), retryMetrics, network-level logging, compression
Interceptors — auth header, logging, and retry
// ── Auth Interceptor — attaches token to every request ────────────────── class AuthInterceptor(private val tokenProvider: TokenProvider) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val token = tokenProvider.getAccessToken() val request = chain.request().newBuilder() .header("Authorization", "Bearer $token") .build() return chain.proceed(request) } } // ── Logging Interceptor (HttpLoggingInterceptor from OkHttp library) ────── val logging = HttpLoggingInterceptor().apply { level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY // full body in debug else HttpLoggingInterceptor.Level.NONE // nothing in release (security!) } // ── Retry Interceptor — retry transient network failures ──────────────── class RetryInterceptor(private val maxRetries: Int = 3) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { var attempt = 0 var lastError: IOException? = null while (attempt < maxRetries) { try { val response = chain.proceed(chain.request()) if (response.isSuccessful) return response if (response.code !in listOf(502, 503, 504)) return response // non-transient response.close() } catch (e: IOException) { lastError = e } attempt++ Thread.sleep(1000L * attempt) // exponential backoff } throw lastError ?: IOException("Failed after $maxRetries retries") } } // ── Custom header interceptor — add platform/version info to all requests class PlatformInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request().newBuilder() .header("X-Platform", "Android") .header("X-App-Version", BuildConfig.VERSION_NAME) .header("X-Device-Model", Build.MODEL) .build() return chain.proceed(request) } }

Retrofit

Retrofit turns your HTTP API into a Kotlin interface. It sits directly on top of OkHttp — Retrofit handles type-safe request building and response conversion; OkHttp handles the actual bytes on the wire. Every Retrofit call ultimately becomes an OkHttp Request, runs through your interceptor chain, and the response body gets deserialized by a Converter (Gson, Moshi, or Kotlin Serialization) into your data class.

📋 Retrofit architecture — how the layers compose
ViewModel / Repository Retrofit Interface (ProductApi) @GET, @POST, @Path, @Query, @Body — suspend fun Retrofit Engine Builds OkHttp Request • Calls OkHttpClient • Converts response via Converter OkHttpClient Converter Moshi / Gson / kotlinx CallAdapter suspend / Flow / Result Retrofit = Type-safe interface + OkHttp wire + Converter for your model classes
Retrofit — setup, interface definition, and Hilt integration
// ── API Interface ──────────────────────────────────────────────────────── interface ProductApi { @GET("products") suspend fun getProducts( @Query("page") page: Int, @Query("limit") limit: Int = 20 ): ProductListResponse @GET("products/{id}") suspend fun getProduct(@Path("id") id: String): Product @POST("products") suspend fun createProduct(@Body product: CreateProductRequest): Product @PUT("products/{id}") suspend fun updateProduct(@Path("id") id: String, @Body body: UpdateRequest): Product @DELETE("products/{id}") suspend fun deleteProduct(@Path("id") id: String): Response<Unit> @Multipart @POST("products/{id}/image") suspend fun uploadImage( @Path("id") id: String, @Part image: MultipartBody.Part ): UploadResponse } // ── Hilt Module ────────────────────────────────────────────────────────── @Module @InstallIn(SingletonComponent::class) object NetworkModule { @Provides @Singleton fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .addInterceptor(authInterceptor) .addInterceptor(HttpLoggingInterceptor().apply { level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE }).build() @Provides @Singleton fun provideRetrofit(client: OkHttpClient): Retrofit = Retrofit.Builder() .baseUrl("https://api.example.com/v1/") // trailing slash required .client(client) .addConverterFactory(MoshiConverterFactory.create()) .build() @Provides @Singleton fun provideProductApi(retrofit: Retrofit): ProductApi = retrofit.create(ProductApi::class.java) }

Token refresh without race conditions

Token refresh is the most common networking interview question for senior Android roles. The naive approach — refresh inside AuthInterceptor when you get a 401 — works until two requests fire simultaneously, both get 401s, both try to refresh, and your first valid token is overwritten by the second refresh which was using an already-invalidated token. The server returns 401 again. Your app is now in an infinite logout loop.

The fix is a Mutex. Kotlin's Mutex is a coroutine-aware mutual exclusion lock. The first coroutine to reach the refresh logic acquires the lock, calls the refresh endpoint, stores the new token, and releases the lock. Every other coroutine that was also blocked on a 401 waits, then enters the lock — and immediately checks whether the token was already refreshed while it was waiting. If yes, it reuses the new token without calling the refresh endpoint again. At-most-one refresh call, regardless of how many concurrent 401s arrive.

⚠️ Classic Interview Trap

Candidates often say "retry the request in the interceptor." That's the right idea but the wrong detail. The critical part is preventing concurrent refreshes — without Mutex, you get a token refresh storm: N simultaneous 401s → N refresh calls → all but one fail → user is logged out. Always explain the race condition before presenting the Mutex fix.

Token refresh interceptor — Mutex-guarded, at-most-one refresh call
class AuthInterceptor( private val tokenStore: TokenStore, private val authApi: AuthApi // a separate OkHttpClient with no auth interceptor! ) : Interceptor { // Shared across all concurrent calls — the lock that prevents refresh storms private val refreshMutex = Mutex() override fun intercept(chain: Interceptor.Chain): Response { // 1. Attach current access token val response = chain.proceed(chain.request().withToken(tokenStore.accessToken())) // 2. If not 401, return immediately — happy path, no overhead if (response.code != 401) return response response.close() // 3. Token expired — acquire Mutex before touching the refresh endpoint val newToken = runBlocking { refreshMutex.withLock { // KEY CHECK: did another coroutine already refresh while we waited for the lock? val current = tokenStore.accessToken() if (current != chain.request().header("Authorization")?.removePrefix("Bearer ")) { // Token changed while we waited → use the new one, skip refresh return@withLock current } // We are the first coroutine with the stale token — do the actual refresh val refreshToken = tokenStore.refreshToken() ?: run { tokenStore.clearTokens(); return@withLock null } try { val tokens = authApi.refreshTokens(refreshToken) tokenStore.saveTokens(tokens.accessToken, tokens.refreshToken) tokens.accessToken } catch (e: HttpException) { tokenStore.clearTokens() // refresh failed → force logout null } } } // 4. No valid token means user must log in again if (newToken == null) throw TokenExpiredException() // 5. Retry original request with the fresh token return chain.proceed(chain.request().withToken(newToken)) } private fun Request.withToken(token: String?) = newBuilder() .header("Authorization", "Bearer $token").build() }
✅ Critical Detail: Two OkHttpClients

The authApi used for refreshing tokens must be built with a separate OkHttpClient — one that has no auth interceptor. If you use the same client, the refresh call itself will get intercepted, get a 401, try to refresh again, recurse infinitely, and blow the stack. In Hilt, name it @Named("unauth").


Certificate pinning

TLS prevents eavesdropping, but it trusts any certificate signed by any of ~150 root CAs in the Android trust store. A certificate pinning step says: "I don't just trust any valid TLS certificate for this domain — I only trust this specific certificate." A rogue CA or a MITM attack with a forged cert gets rejected even if it's technically valid TLS.

OkHttp implements pinning by comparing the SHA-256 hash of the certificate's public key against a set of pre-configured pins. You can pin the leaf certificate, an intermediate CA, or the root CA. Pinning the root or intermediate is more flexible — leaf certificates rotate more frequently.

⚠️ Shadow Pin Rotation — the one thing that kills apps

The #1 mistake with certificate pinning: pinning a single leaf cert and then rotating it without pre-deploying the new pin. Users on the old app version will get CertificatePinException for every request — the app is effectively bricked until they update. The fix is shadow pin rotation: add the new pin to your config before the cert rotation. Both pins are valid simultaneously. After the cert rotates, remove the old pin in a future release. The overlap window is typically 30–90 days.

OkHttp certificate pinning with shadow rotation support
// CertificatePinner uses "sha256/" prefix + base64-encoded SHA-256 of the public key // Get the pin: openssl x509 -in cert.pem -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64 val pinner = CertificatePinner.Builder() .add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") // current cert .add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // shadow pin (next cert) .add("*.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") // wildcard support .build() val client = OkHttpClient.Builder() .certificatePinner(pinner) .build() // ── Network Security Config alternative (declarative, no code changes needed) ── // res/xml/network_security_config.xml: // <network-security-config> // <domain-config> // <domain includeSubdomains="true">api.example.com</domain> // <pin-set expiration="2026-01-01"> // <pin digest="SHA-256">AAAAAAA...=</pin> // <pin digest="SHA-256">BBBBBBB...=</pin> <!-- shadow pin --> // </pin-set> // </domain-config> // </network-security-config> // In AndroidManifest.xml: // <application android:networkSecurityConfig="@xml/network_security_config" ... /> // ── Testing: expect CertificatePinException for unknown pins ────────────── try { okHttpClient.newCall(request).execute() } catch (e: SSLPeerUnverifiedException) { // Certificate doesn't match pin — potential MITM attack or misconfiguration analytics.track("pin_violation", mapOf("host" to request.url.host)) throw SecurityException("Certificate pin violation for ${request.url.host}") }

HTTP caching

OkHttp has a built-in HTTP cache that sits at the CacheInterceptor layer — before any network call. When enabled, OkHttp stores responses on disk and serves them when the server says they're still valid. This is zero-effort caching that works with any Retrofit API, requires no changes to your repository code, and dramatically reduces latency for read-heavy endpoints.

The cache honours standard HTTP cache headers. The server controls how long a response is fresh via Cache-Control: max-age=300 (5 minutes). OkHttp serves from disk without hitting the network if the response is within that window. After expiry, OkHttp sends a conditional request with If-None-Match or If-Modified-Since; the server returns 304 Not Modified if nothing changed — no body to download, just a header round-trip.

OkHttp cache — setup, forced offline mode, and cache-only patterns
// ── Enable disk cache (10 MB) ───────────────────────────────────────────── val cacheDir = File(context.cacheDir, "http_cache") val cache = Cache(cacheDir, 10L * 1024 * 1024) // 10 MB val client = OkHttpClient.Builder() .cache(cache) .addInterceptor(offlineCacheInterceptor) // application interceptor — overrides request headers .addNetworkInterceptor(onlineCacheInterceptor) // network interceptor — overrides response headers .build() // ── Offline interceptor: serve stale cache when network is unavailable ──── class OfflineCacheInterceptor(private val connectivityManager: ConnectivityManager) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = if (!connectivityManager.isNetworkAvailable()) { // No internet → serve stale cache up to 7 days old chain.request().newBuilder() .header("Cache-Control", "public, only-if-cached, max-stale=${60 * 60 * 24 * 7}") .build() } else chain.request() return chain.proceed(request) } } // ── Network interceptor: force re-validate on every real network call ──── class OnlineCacheInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request()).newBuilder() // Override server's Cache-Control if it says no-store/no-cache — useful when API is stingy .header("Cache-Control", "public, max-age=300") // cache for 5 minutes .build() } // ── Force fresh: bypass cache for this request only ────────────────────── val freshRequest = Request.Builder() .url(url) .header("Cache-Control", "no-cache") // revalidate even if cache is fresh .build() // ── Cache-only (no network attempt): use for graceful offline fallback ──── val cacheOnlyRequest = Request.Builder() .url(url) .header("Cache-Control", "only-if-cached, max-stale=${Int.MAX_VALUE}") .build()
Cache-Control directiveWho sends itEffect
max-age=NServer responseResponse is fresh for N seconds — OkHttp serves from cache without network
no-cacheClient requestForce revalidation with server even if cache is fresh (still uses 304 if unchanged)
no-storeServer responseNever cache this response — used for auth tokens, payment data
only-if-cachedClient requestUse cache or return 504 — never make a network call; ideal for offline mode
max-stale=NClient requestAccept stale cache up to N seconds past expiry — offline fallback

Connection pooling

Every HTTP request requires a TCP connection. Establishing a TCP connection involves a three-way handshake (SYN → SYN-ACK → ACK), which on mobile networks can take 100–300ms alone. Add TLS negotiation on top and you're looking at 400–600ms before the first byte of your actual request leaves the device. For an app that makes 20 API calls on startup, that's 8–12 seconds of pure connection overhead if every request uses a new connection.

OkHttp's ConnectionPool solves this by keeping TCP connections alive after a response is received. When the next request targets the same host, OkHttp reuses an existing connection from the pool — no TCP handshake, no TLS negotiation, request bytes are on the wire immediately. HTTP/2 goes further: it multiplexes multiple requests over a single connection simultaneously (no head-of-line blocking).

Connection pool configuration and HTTP/2
// Default pool: 5 idle connections, 5 min keep-alive // Tune for your app: more connections for high-concurrency apps val connectionPool = ConnectionPool( maxIdleConnections = 10, // max idle connections to keep per host keepAliveDuration = 10, // how long to keep idle connections alive timeUnit = TimeUnit.MINUTES ) val client = OkHttpClient.Builder() .connectionPool(connectionPool) .protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1)) // prefer HTTP/2, fallback to 1.1 .build() // ── Common mistake: creating multiple OkHttpClient instances ────────────── // Each client has its OWN connection pool and thread pool. // Two clients = no connection reuse between them = wasted resources. // ALWAYS inject a single @Singleton OkHttpClient via Hilt. // ── Inspecting pool stats (for debugging/metrics) ────────────────────── val pool = client.connectionPool Log.d("Network", "Idle: ${pool.idleConnectionCount()}, Total: ${pool.connectionCount()}")
ℹ️ HTTP/2 multiplexing

HTTP/2 sends multiple requests over a single TCP connection simultaneously using streams. With HTTP/1.1, if you have 5 parallel API calls, OkHttp needs up to 5 TCP connections. With HTTP/2, all 5 go over one connection — no connection setup overhead, and the OS has less socket management to do. OkHttp negotiates HTTP/2 automatically via ALPN during TLS handshake. Most modern Android backends support it. Check with: response.protocol.