OkHttp is the HTTP client that actually moves the bytes; Retrofit sits on top of it and turns an annotated Kotlin interface into typed calls that OkHttp executes.
OkHttp: connections and the connection pool, HTTP/2, TLS, gzip, redirects, timeouts, the response cache and the interceptor chain.
Retrofit: builds each request from the annotations, hands it to an OkHttp Call and converts the body into your type through a converter (kotlinx.serialization, Moshi, Gson).
Interceptors, timeouts and the cache are configured on the OkHttpClient, not on Retrofit.
Create one OkHttpClient for the app and share it: it owns the connection pool and the threads, so extra instances waste both.
OkHttp on its own fits raw HTTP: WebSockets, arbitrary URLs. Both are JVM/Android only, so a multiplatform module usually uses the Ktor client instead.
AndroidMiddle
Android Networking
Retrofit, OkHttp, interceptors, kotlinx.serialization, error handling
Questions closed
0 / 25
- 0Review
- 3Asked often
Questions
They tell Retrofit where each argument goes in the HTTP request (the URL path, the query string, the body, a form field or a header), while @Url replaces the whole URL.
@Path("id"): fills {id} in the relative URL; the value is percent-encoded.
@Query("q"): adds ?q=…; a null value is omitted and a List repeats the key. @QueryMap takes a dynamic set.
@Body: the converter serializes the object (usually to JSON) as the request body.
@Field: one field of a form body; the method must be marked @FormUrlEncoded. For file uploads the pair is @Multipart with @Part.
@Header("X-…"): a per-call header; @Headers(...) on the method adds fixed ones.
@Url: a full or relative URL passed at runtime, such as the next link of a paginated response.
A header or parameter needed on every call (auth, locale, API key) belongs in an interceptor, not on each method.
execute() blocks the calling thread, enqueue() runs the call on OkHttp's thread pool and calls you back, and a suspend function uses enqueue() under the hood, so it is main-safe and needs no withContext(Dispatchers.IO).
Call<T>.execute(): synchronous. On the main thread it throws NetworkOnMainThreadException; elsewhere it holds a thread for the whole round trip. Cancel it with call.cancel().
Call<T>.enqueue(callback): asynchronous, the older callback style. On Android, Retrofit delivers the callback on the main thread.
suspend fun: enqueued on OkHttp's dispatcher threads, the body is parsed there too, and the coroutine resumes on its own dispatcher. Cancelling the coroutine cancels the HTTP call.
Switch dispatchers for blocking code (execute(), reading a ResponseBody stream yourself, file I/O), not for code that is merely slow. CPU-heavy work on the result goes to Dispatchers.Default.
Four causes cover almost every case: the call runs on the main thread, the INTERNET permission is missing, the URL is plain http and cleartext is blocked, or localhost points at the emulator itself.
**NetworkOnMainThreadException**: a blocking call (execute(), URL.openStream()) on the main thread. Use a suspend function, or move blocking code off the main thread.
**No INTERNET permission in the manifest**: it is a normal permission granted at install, but without it every socket fails, typically with SecurityException: Permission denied (missing INTERNET permission?).
Cleartext blocked: since Android 9 (target API 28) plain HTTP is off by default, and OkHttp reports CLEARTEXT communication to <host> not permitted by network security policy. Use HTTPS, or allow one dev host in a debug-only network security config.
**localhost on the emulator**: that is the emulator itself; the host machine is 10.0.2.2.
An uncaught IOException or HttpException thrown by a suspend call in viewModelScope also crashes the app, so catch it in the repository.
You observe connectivity with a ConnectivityManager network callback, but you never gate a request on it: make the call and treat an IOException as the offline signal.
Why a pre-check is wrong:
It races: the network can drop between the check and the call, so you need the failure path anyway.
Connected is not reachable: a captive portal, a broken VPN or a DNS failure all look like a live network.
What the callback is good for:
An offline banner, and an automatic retry when the network comes back.
Postponing large downloads on a metered network (NET_CAPABILITY_NOT_METERED).
Checking NET_CAPABILITY_VALIDATED, which means the system confirmed real internet access.
activeNetworkInfo and isConnected are deprecated; read NetworkCapabilities instead. ACCESS_NETWORK_STATE is a normal permission. For work that only has to run once online, give WorkManager a NetworkType.CONNECTED constraint instead of watching the network yourself.
HTTP/1.1 carries one request at a time per connection, HTTP/2 multiplexes many streams over one TLS connection, and HTTP/3 does the same over QUIC on UDP; OkHttp speaks HTTP/1.1 and HTTP/2, but not HTTP/3.
HTTP/1.1: keep-alive reuses a connection, but parallel calls need parallel connections, each paying for DNS, TCP and TLS handshakes.
HTTP/2: negotiated automatically during the TLS handshake (ALPN) when the server supports it. One connection per host carries every call, with compressed headers, which is why one shared OkHttpClient, with one connection pool, matters so much.
HTTP/2's weak spot: one lost TCP packet stalls every stream on the connection, which hurts on lossy mobile networks.
HTTP/3 (QUIC): loss recovery per stream, a faster handshake, and connection migration when the phone moves from Wi-Fi to cellular. On Android you get it through Cronet: the platform HttpEngine on Android 14+, or Cronet from Google Play services.
TLS 1.3: on by default since Android 10, it saves a round trip on every new connection.
The practical point: reuse connections (one client, HTTP/2 on the server, few distinct hosts). Every extra host is another handshake over a slow radio.
It returns a java.lang.reflect.Proxy implementing your interface: every method call goes to one handler that parses that method's annotations once, builds an OkHttp request, and uses a converter for the body and a call adapter for the return type.
How a call is handled
On the first call a method's annotations are parsed into a cached ServiceMethod; later calls reuse it. So a typo in an annotation fails on the first call, unless validateEagerly(true) (typical for debug) parses every method at create().
Converter (Converter.Factory) turns a ResponseBody into your type and a @Body object into a RequestBody. Factories are asked in order and the first that accepts the type wins, so ScalarsConverterFactory goes before the JSON one.
Call adapter (CallAdapter.Factory) turns OkHttp's Call into the declared return type: Call<T>, an RxJava type or a custom result type. suspend functions work out of the box.
It has to be an interface, because Proxy cannot subclass a class, and R8 must keep the interfaces and their generic signatures; Retrofit ships those keep rules.
Patterns interviewers listen for
Dynamic Proxy (create), Builder (Retrofit.Builder), Factory (converter and call adapter factories), Adapter (CallAdapter), and Chain of Responsibility in OkHttp's interceptors.
Every call builds a new OkHttpClient and Retrofit, and with them a new connection pool, thread pool and cache, so no connection is ever reused, each request pays a fresh TCP + TLS handshake, and idle sockets and threads pile up.
Why it is slow and leaks: the connection pool lives inside the client, so a new client cannot reuse the warm connection of the previous one: every request pays one to three extra round trips (hundreds of milliseconds on mobile), HTTP/2 multiplexing never kicks in, and each discarded client holds idle sockets (5 minutes by default) and dispatcher threads (60 seconds).
The cache: OkHttp forbids two Cache instances on one directory at the same time; parallel calls here create exactly that, which can corrupt the journal and quietly disable caching.
The fix: build the client and the service once, as DI singletons, and inject UserApi. When one endpoint needs other settings, derive a client with client.newBuilder(), which shares the pool, dispatcher and cache.
Catching it: in review, any OkHttpClient.Builder() or Retrofit.Builder() outside the DI module is a red flag; at runtime, an EventListener that logs connectStart shows a new connection for every request to the same host.
Retrofit resolves the annotation path against baseUrl the way a browser resolves a link: a leading / means "from the host root", so @GET("/users") requests https://api.example.com/users and loses /v2.
@GET("users"), without the leading slash, gives https://api.example.com/v2/users; that is the habit to keep.
baseUrl must end with /, otherwise baseUrl(...) throws.
user("a/b") requests /v2/users/a%2Fb, because @Path values are percent-encoded. @Path(value = "id", encoded = true) is only for a value that really is several segments.
A full URL in the annotation, or an @Url argument, ignores baseUrl entirely.
@GET("search?q={q}") is rejected: dynamic query values go through @Query.
Two hosts: build one Retrofit per base URL, both on the same OkHttpClient. A host chosen at runtime, such as a staging switch, is simpler as an interceptor that rewrites request.url.newBuilder().host(...). One MockWebServer test asserting the recorded path catches this whole class of bug.
It generates serializers at compile time, so it respects Kotlin nullability and default values, needs no reflection or R8 keep rules and works in multiplatform code; for a foreign API you make the Json instance tolerant of extra, missing and unexpected values.
Why not Gson
Gson creates objects through Unsafe without calling the constructor, so a missing field leaves null in a non-null String and default values are ignored; the crash happens later, far from the parsing.
It is reflection-based and needs keep rules. Moshi with KSP code generation is the other Kotlin-aware option.
Settings that matter
ignoreUnknownKeys = true: without it, a new field on the server breaks decoding on every installed version.
coerceInputValues = true: a null for a non-null property, or an unknown enum constant, falls back to the property's default.
@SerialName("full_name") renames a field; @Transient excludes one (it needs a default).
A property without a default is required and throws when absent, which is sometimes exactly what you want.
A sealed hierarchy with @SerialName on each subclass decodes a mixed list by its "type" key.
Create one Json instance and share it with the Retrofit converter.
Both implement Interceptor, but an application interceptor runs once per call on the logical request, while a network interceptor runs on every real round trip (each redirect and retry) and sees the request as it goes on the wire.
Application (addInterceptor): runs even when the response comes from the cache and can answer without touching the network. Common headers (Accept-Language, API version, auth), a query parameter on every call, request signing and debug stubs go here.
Network (addNetworkInterceptor): sees the headers OkHttp adds itself (Host, Accept-Encoding) and the compressed body; use it for wire-level metrics or to rewrite Cache-Control on responses.
Order matters: interceptors run in the order they are added. A logger added before the auth interceptor prints the request without Authorization, so add HttpLoggingInterceptor last among the application interceptors.
Logging in release: Level.NONE outside debug builds and redactHeader("Authorization"); Level.BODY also buffers the whole body in memory.
Not for token refresh: a 401 is handled by OkHttp's Authenticator, not an interceptor.
Keep each interceptor single-purpose and register them once, on the shared OkHttpClient.
A ResponseBody is a one-shot stream over the open connection: string() reads it to the end and closes it, so when Retrofit's converter later reads the same body it finds a closed stream.
string() and bytes() load the whole body into memory and close it; a second read throws.
To look without consuming, use response.peekBody(maxBytes), which copies a bounded prefix and leaves the real body untouched.
Or read it and return a rebuilt response with text.toResponseBody(contentType) (with a size limit, or a download gets buffered whole).
A body nobody closes keeps its connection out of the pool, and OkHttp warns A connection to ... was leaked. Did you forget to close a response body?.
In Retrofit the converter closes the body for suspend fun x(): Dto. With Response<T>, Retrofit has already buffered errorBody(), but it can be consumed only once: read it once and keep the String. A raw ResponseBody return type is entirely yours to close, ideally with use { }.
Request is immutable: newBuilder()…build() returns a new request with the header, but the code discards it and proceeds with the original, so no call ever carries Authorization and the server answers 401.
Why it compiles: the if is used as a statement, so its result is silently dropped; by default Kotlin does not warn about an ignored return value (only the opt-in return-value checker flags it). HttpUrl, Response and OkHttpClient follow the same immutable-object-plus-builder pattern, and the same mistake hits them too.
The fix: make the rebuilt request the value passed to chain.proceed(...): use if as an expression, or build it with original.newBuilder().apply { token?.let { header("Authorization", "Bearer $it") } }.build().
header() replaces an existing value, addHeader() appends a second one; for Authorization you want header().
Catching it: a MockWebServer test: make one call, then assert that server.takeRequest().headers["Authorization"] equals Bearer …. While debugging, an HttpLoggingInterceptor at level HEADERS, added after this interceptor, shows the header is missing; add redactHeader("Authorization") so the token stays out of logs.
A throwing suspend call fails in three different ways: IOException (no connection, timeout), HttpException (the server answered non-2xx) and SerializationException (the body does not match the model), and the repository maps each to a domain error once.
IOException: offline, DNS failure, timeout, connection reset. Retryable; tell the user it is the network.
HttpException: read code() and parse response()?.errorBody() for the server's error payload. 401, 404 and 422 mean different things to the UI.
SerializationException: the contract drifted. A bug to log, not something to retry.
Response<T> as the return type gives status, headers and error body without an exception, which is handy when a non-2xx is a normal outcome, such as 404 for "not found".
A custom CallAdapter.Factory can make every endpoint return a sealed result type, at the cost of some infrastructure.
Do not let HttpException reach the ViewModel: the UI should receive a domain error it knows how to render. And never report CancellationException as a failure.
viewModelScope is cancelled, Retrofit calls call.cancel(), OkHttp closes the socket (HTTP/1.1) or resets the stream (HTTP/2) and the coroutine gets CancellationException, but the server may already have processed the request.
The path: the scope's job is cancelled, Retrofit's suspend bridge calls call.cancel(), and the in-flight call fails with IOException: Canceled inside OkHttp, which the bridge turns into CancellationException.
Cancellation is not a rollback: a cancelled POST can still have created the order. Send an idempotency key so a retry does not duplicate it.
Work that must outlive the screen, such as sending a message, belongs in an application-scoped CoroutineScope or WorkManager, not in viewModelScope.
A generic catch (e: Exception) also catches CancellationException; catch IOException/HttpException, or rethrow it first.
Cancellation is also a feature: in search-as-you-type, flatMapLatest cancels the previous request on every keystroke.
OkHttp has connect, read, write and call timeouts; on its own it only retries a failed connection attempt (another route or a fresh connection) and follows redirects; retrying a request the server may already have processed is your decision.
The timeouts
connectTimeout: the TCP connect only, 10 s by default; the TLS handshake that follows runs under readTimeout.
readTimeout / writeTimeout set the longest gap between bytes, 10 s each by default. Not a total budget: a server dripping bytes never trips it.
callTimeout: the whole call, including redirects and the body. Off by default, and the only one that bounds a request.
chain.withReadTimeout(...) in an interceptor widens one endpoint instead of the whole client.
Your own retry
Only for idempotent requests: GET, PUT, DELETE. A POST only with an Idempotency-Key that the server deduplicates.
Retry on IOException, 5xx and 429 (honour Retry-After); never on other 4xx.
Exponential backoff with jitter and a cap, so devices do not all return at the same moment after an outage.
Put the loop in the repository with delay(), not in an interceptor, where Thread.sleep ignores coroutine cancellation.
Do the arithmetic: a 30 s call timeout with three attempts is a 90 s spinner.
Use OkHttp's Authenticator: it is called only when the server answers 401, returns the request to retry with the new token (or null to give up), and OkHttp replays it for you; a refresh hidden in an interceptor easily loops or races.
Split the roles: an application interceptor attaches the current token; the Authenticator refreshes it.
One refresh for many 401s: ten parallel calls fail together. Refresh under a Mutex and compare: if the stored token already differs from the one the failed request carried, another call has refreshed it, so reuse it.
Know when to stop: if the retried request gets 401 again, return null. If the refresh endpoint rejects the refresh token, clear the session and go to login; a network error during refresh is not a logout.
No recursion: the refresh call must go through a client without this authenticator, or a 401 from refresh triggers refresh again.
authenticate() runs on an OkHttp thread and is blocking by contract, so runBlocking inside it is acceptable.
Refreshing shortly before expiry saves a round trip, but keep the 401 path: clock skew and server-side revocation make it unavoidable.
OkHttp has an opt-in disk cache (Cache(dir, maxSize) on the client) that follows standard HTTP caching (Cache-Control, ETag revalidation) and serves stale responses offline only for requests sent with only-if-cached.
The server's max-age decides how long a response is fresh; no-store turns caching off for it.
After expiry OkHttp sends If-None-Match; a 304 Not Modified refreshes the entry without downloading the body again, which is the biggest practical win.
When the server sends no useful headers, a network interceptor can rewrite Cache-Control on the responses.
Offline reads: send the request with only-if-cached, max-stale=… (or CacheControl.FORCE_CACHE). With nothing cached, OkHttp returns a synthetic 504 Unsatisfiable Request.
response.cacheResponse and response.networkResponse show where a response came from.
For an offline-first app the HTTP cache is not the source of truth: you cannot query it, it expires by its own rules, and the system may clear cacheDir. Keep the data in Room and treat the network as a refresh.
Mark the method @Multipart and pass the file as a MultipartBody.Part built from a streaming RequestBody; progress comes from wrapping that body and counting bytes as OkHttp writes them.
@Part: a RequestBody for a plain field, or MultipartBody.Part.createFormData(name, filename, body) for a file.
File.asRequestBody(mediaType) streams from disk. Reading the file into a ByteArray first is how large uploads hit OutOfMemoryError.
A content:// Uri from the photo picker is not a File: write a RequestBody whose writeTo copies contentResolver.openInputStream(uri) into the sink.
@Body RequestBody sends raw bytes without a multipart envelope, when the API takes the file directly.
Progress: override writeTo with a counting ForwardingSink, and throttle what you emit: it fires every few kilobytes.
An upload that must survive leaving the screen belongs in WorkManager, and very large files need a resumable protocol where the server tracks the offset.
An image loader downloads off the main thread, decodes the bitmap at the size you display rather than the source size, keeps memory and disk caches, and cancels a request when its item leaves the screen.
Downsampling: a 4000×3000 photo decoded at full size is about 48 MB as an ARGB bitmap; decoded for a 48 dp avatar (144 px at 3x density) it is about 80 KB.
Caches: a bounded LRU memory cache keyed by URL and size, plus a disk cache shared across the app.
Lifecycle: requests are cancelled when the composable leaves composition or a view is recycled.
Still on you:
Give the image a bounded size; unbounded constraints mean decoding at the original resolution.
Ask the server for a thumbnail when the API offers one: clever decoding does not save the bandwidth.
Reserve space (aspectRatio or a fixed size) so the list does not jump as images land, and give lazy items stable keys.
Share the app's OkHttpClient with the loader for auth headers and the connection pool, and clear its caches on logout when images are private.
Coil 3 is the usual choice for Compose and multiplatform; Glide is common in View-based code.
Cursor pagination is the safe default for a feed that changes, because offset pages shift when rows are inserted; Paging 3 loads pages through a PagingSource, and RemoteMediator writes network pages into Room so the list also works offline.
Offset vs cursor
Offset or page number (?page=3&size=20): simple and allows jumping to page N, but a new item at the top shifts every page, giving duplicates and skipped rows.
Cursor (?after=<id>): the server returns a next cursor; stable under inserts and cheap for the database, with no random access.
Paging 3
PagingSource.load(params) returns LoadResult.Page(data, prevKey, nextKey) or LoadResult.Error; the key is your page number or cursor.
Pager(...).flow.cachedIn(viewModelScope), where cachedIn keeps loaded pages across rotation.
collectAsLazyPagingItems() in Compose; loadState.refresh drives the full-screen state and loadState.append the retry row at the bottom.
Offline with RemoteMediator
Room's DAO returns the PagingSource, so the UI only ever observes the database.
When local data runs out, RemoteMediator.load(loadType, state) fetches the next page and stores the rows and the next cursor in one transaction.
initialize() decides whether cached data is fresh enough to skip the first refresh.
For a list of a few dozen items, Paging is unnecessary complexity.
Run Retrofit against MockWebServer (a real HTTP server on localhost), so OkHttp, your interceptors, the converter and the error mapping all execute, and assert both the request your client sent and how it handled the scripted reply.
enqueue(MockResponse()) scripts replies in order; a Dispatcher answers by path when calls run in parallel.
takeRequest() returns what the client actually sent: path, query, the headers your interceptor added, the serialized body.
Socket policies and delays reproduce dropped connections and timeouts deterministically.
Worth covering: a payload captured from the real API, 401/404/500 mapped to the right domain error, malformed JSON, an unknown extra field.
Keep the layers honest: repository tests use a fake implementation of the API interface, ViewModel tests use a fake repository, and only the small MockWebServer suite knows HTTP exists. OkHttp 5 adds mockwebserver3 with an immutable MockResponse.Builder; the classic API below is what most codebases still use.
REST is the simple, cache-friendly default; GraphQL lets a screen fetch exactly the fields it needs in one round trip; gRPC brings strict Protobuf contracts, compact binary payloads and streaming, at the cost of tooling and HTTP caching.
REST + JSON: every tool understands it, standard HTTP caching works (ETag, CDN), easy to debug. Downsides: over-fetching, and one screen often needs several calls.
GraphQL: one endpoint and a typed schema; the client picks the fields. Apollo Kotlin generates models from your queries and has a normalized cache. Downsides: requests are usually POST, so HTTP caching is lost unless you use persisted queries over GET; the backend has to guard against expensive queries; errors often arrive inside a 200 response.
gRPC / Protobuf: generated stubs (grpc-kotlin, or Square's Wire), small binary messages, server and bidirectional streaming over HTTP/2. Downsides: needs HTTP/2 end to end, payloads are not human-readable, and some proxies and browsers need a gateway.
Schema evolution: Protobuf and GraphQL make additive changes safe by design; REST relies on discipline and ignoreUnknownKeys on the client.
The backend usually makes this choice. The mobile engineer's job is to know what each costs on a flaky network: payload size, number of round trips, and whether responses can be cached.
WebSocket for two-way traffic such as chat, SSE for a one-way stream from the server, long polling only as a fallback, and FCM for anything that must reach the device while the app is not running.
The options
WebSocket: one upgraded connection carrying frames both ways; client.newWebSocket(request, listener) in OkHttp. Interceptors apply to the handshake, so auth headers work as usual.
SSE: plain HTTP streaming from server to client, with event ids and reconnect built into the protocol; the okhttp-sse artifact implements it. It survives proxies that break upgrades.
Long polling: the server holds each request until there is data. Easy on the server, worst for battery and connection churn.
FCM: no socket survives a killed process or Doze; a push message wakes the app to sync.
What a senior answer adds
Lifecycle: keep the socket open only while someone is collecting (callbackFlow plus WhileSubscribed) and close it in the background.
Reconnect: exponential backoff with jitter, then resync what was missed, by last event id or a REST call.
Heartbeats: pingInterval, because carrier NATs drop idle connections silently.
Isolation: OkHttp runs at most 5 calls per host by default and an open WebSocket or SSE call holds one of those slots, so give streams their own Dispatcher and no callTimeout.
@Streaming stops Retrofit from buffering the whole body in memory, and a Range request starting at the size of the partial file on disk lets the download resume after a dropped connection.
Streaming to disk
Without @Streaming the entire ResponseBody is read into memory before your function returns; that is the OOM.
With it you get the live stream: copy it to a file with Okio on Dispatchers.IO and close both source and sink with use.
Write to a .part file and rename it when complete, so a killed process never leaves a truncated file that looks finished.
Resuming
Send Range: bytes=<downloaded>-. A 206 Partial Content means append; the total size is in Content-Range, not in Content-Length.
A 200 means the server ignored the range, so start over from zero.
Send If-Range with the first response's ETag, so a file that changed on the server restarts instead of splicing two versions together.
Where it runs
callTimeout covers the body too, so keep it at 0 for downloads and rely on readTimeout.
Run it in a WorkManager CoroutineWorker with a network constraint and setForeground; viewModelScope dies with the screen.
For a user-visible file in public storage, the system DownloadManager already does all of this.