🌐 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
⚡ Retrofit
🔐 Interceptors
🔄 Token Refresh
💾 Caching
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
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 appval 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 funonFailure(call: Call, e: IOException) { /* network error */ }
override funonResponse(call: Call, response: Response) {
response.use { // use{} closes body automaticallyif (!response.isSuccessful) throwIOException("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
Feature
Application Interceptor
Network Interceptor
Added via
addInterceptor()
addNetworkInterceptor()
Runs when
Always — even for cached responses
Only on actual network calls
Sees
Original request, final response
Actual wire request, compressed response
Can short-circuit
Yes — return without calling proceed()
Yes, but only after connection is established
Best for
Auth headers, logging (human-readable), retry
Metrics, network-level logging, compression
Interceptors — auth header, logging, and retry
// ── Auth Interceptor — attaches token to every request ──────────────────classAuthInterceptor(private val tokenProvider: TokenProvider) : Interceptor {
override funintercept(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 debugelseHttpLoggingInterceptor.Level.NONE // nothing in release (security!)
}
// ── Retry Interceptor — retry transient network failures ────────────────classRetryInterceptor(private val maxRetries: Int = 3) : Interceptor {
override funintercept(chain: Interceptor.Chain): Response {
var attempt = 0var lastError: IOException? = nullwhile (attempt < maxRetries) {
try {
val response = chain.proceed(chain.request())
if (response.isSuccessful) return response
if (response.code !inlistOf(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 requestsclassPlatformInterceptor : Interceptor {
override funintercept(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
Retrofit — setup, interface definition, and Hilt integration
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.
classAuthInterceptor(
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 stormsprivate val refreshMutex = Mutex()
override funintercept(chain: Interceptor.Chain): Response {
// 1. Attach current access tokenval response = chain.proceed(chain.request().withToken(tokenStore.accessToken()))
// 2. If not 401, return immediately — happy path, no overheadif (response.code != 401) return response
response.close()
// 3. Token expired — acquire Mutex before touching the refresh endpointval 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 refreshreturn@withLock current
}
// We are the first coroutine with the stale token — do the actual refreshval refreshToken = tokenStore.refreshToken()
?: run { tokenStore.clearTokens(); return@withLocknull }
try {
val tokens = authApi.refreshTokens(refreshToken)
tokenStore.saveTokens(tokens.accessToken, tokens.refreshToken)
tokens.accessToken
} catch (e: HttpException) {
tokenStore.clearTokens() // refresh failed → force logoutnull
}
}
}
// 4. No valid token means user must log in againif (newToken == null) throwTokenExpiredException()
// 5. Retry original request with the fresh tokenreturn chain.proceed(chain.request().withToken(newToken))
}
private funRequest.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 | base64val 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))
throwSecurityException("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 MBval 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 ────classOfflineCacheInterceptor(private val connectivityManager: ConnectivityManager) : Interceptor {
override funintercept(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 ────classOnlineCacheInterceptor : Interceptor {
override funintercept(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 directive
Who sends it
Effect
max-age=N
Server response
Response is fresh for N seconds — OkHttp serves from cache without network
no-cache
Client request
Force revalidation with server even if cache is fresh (still uses 304 if unchanged)
no-store
Server response
Never cache this response — used for auth tokens, payment data
only-if-cached
Client request
Use cache or return 504 — never make a network call; ideal for offline mode
max-stale=N
Client request
Accept 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 appsval 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.