Android Networking Interview Questions
Android networking questions: how Retrofit and OkHttp work, interceptors, authentication and token refresh, caching, error handling, pagination, file uploads, REST vs GraphQL, HTTPS, and designing a production networking layer.
By Vivek Choudhary, Android engineer · Updated October 2026 · How answers are checked
All 50 questions
- What is Retrofit? How does it work under the hood? Most asked
- What is OkHttp? How does it relate to Retrofit? Most asked
- What is an OkHttp Interceptor? What are the two types and when do you use each? Most asked
- How do you handle API errors in Retrofit? What's the difference between a network error and an HTTP error? Most asked
- What is a Bearer token and how do you add authentication to every request? Most asked
- How do you implement token refresh — automatically refreshing an expired JWT without the user noticing? Most asked
- Which JSON library should you use with Retrofit in 2025 — Gson, Moshi, or Kotlin Serialization? Most asked
- How does OkHttp caching work? How do you implement offline support using the HTTP cache? Most asked
- Scenario: Your API returns different response shapes for success and error. How do you model this in Retrofit?
- Scenario: How do you implement SSL pinning to prevent man-in-the-middle attacks on your API?
- What is the difference between @Query, @Path, @Body, and @Header in Retrofit? Most asked
- What is REST? What are the key HTTP methods and when do you use each? Most asked
- REST vs GraphQL — what are they, how do they differ, and when do you choose each? Most asked
- Scenario: Implement a robust retry mechanism with exponential backoff for flaky network requests.
- How do you use Kotlin Serialization with Retrofit? What are @SerialName and ignoreUnknownKeys?
- Scenario: How do you add logging to all your API requests in debug mode only — without sending any logs in production?
- How do you upload files (images, documents) using Retrofit and Multipart? Most asked
- How do you convert network models (DTOs) to domain models? Why keep them separate? Most asked
- Scenario: How do you test a Repository that makes network calls? What tools and patterns do you use?
- What is an API gateway pattern? How does it affect your Android network code?
- Scenario: Your app needs to paginate through a list of 10,000 products. How do you implement pagination with Retrofit and Paging 3?
- What are network timeouts? How do you configure and choose the right values? Most asked
- How do you use Retrofit with Kotlin coroutines? What are the advantages over callbacks? Most asked
- What is WebSocket and how do you implement real-time communication in Android?
- Scenario: Design the complete networking layer for a production Android app — from OkHttp setup to the ViewModel. What decisions do you make and why?
- What is HTTP/2? How does it improve performance over HTTP/1.1? Most asked
- What is connection pooling in OkHttp? Why does it matter for performance? Most asked
- Scenario: Your app sends user analytics events. Should you batch them or send individually? How do you implement batching?
- What is HTTPS and why is it mandatory in modern Android apps? Most asked
- Scenario: Your app downloads large images and PDFs. How do you implement download progress and cancellation?
- What is the difference between query parameters, path parameters, and request headers? When do you use each? Most asked
- What is Server-Sent Events (SSE)? How does it compare to WebSocket and polling?
- How do you handle API versioning in Android? What happens when the backend changes the API? Most asked
- Scenario: Your app needs to make network requests even when the user is offline — queue them and execute when connectivity is restored.
- What is gzip compression in HTTP and how does OkHttp handle it? Most asked
- Scenario: You need to call three APIs in parallel and merge results for a dashboard screen. How do you do this efficiently?
- What is an idempotency key and when do you need it? Most asked
- Scenario: How do you use MockWebServer to write tests that simulate specific HTTP responses including errors, delays, and timeouts?
- What is the difference between PUT and PATCH? When do you use each in a REST API? Most asked
- What is gRPC? How does it compare to REST and when would you use it in an Android app?
- What is CORS and does it affect Android apps? Most asked
- Scenario: Your API returns a list of 1000 items but your screen only shows 20 at a time. How do you implement cursor-based pagination vs offset pagination?
- How do you handle network state changes (going offline/online) in an Android app? Most asked
- Scenario: Your app has sensitive data in API responses. How do you prevent it from being captured by proxies or network inspection tools?
- What is OkHttp's EventListener? How do you use it to monitor network performance?
- Scenario: A junior dev pushed code that makes network calls on the main thread. How do you detect and prevent this?
- How do you send a custom User-Agent header with Retrofit and why is it useful? Most asked
- Scenario: Your backend uses GraphQL. How do you integrate Apollo Kotlin into an Android app with caching and error handling?
- What is the Retrofit CallAdapter? How do you create a custom one to wrap responses in Result<T>? Most asked
- Scenario: Conduct a complete networking code review. What 10 things do you look for?
1. What is Retrofit? How does it work under the hood?
Retrofit is a type-safe HTTP client for Android. You write an interface with annotated methods, and Retrofit generates the implementation at runtime using reflection and a dynamic proxy. Think of it as a bridge between your code and an HTTP server.
// Step 1 — Define your API interface interface UserApi { @GET("users/{id}") suspend fun getUser(@Path("id") id: String): User @POST("users") suspend fun createUser(@Body request: CreateUserRequest): User @GET("users") suspend fun getUsers(@Query("page") page: Int): List<User> } // Step 2 — Build the Retrofit instance (done once, @Singleton) val retrofit = Retrofit.Builder() .baseUrl ("https://api.example.com/") .addConverterFactory (GsonConverterFactory.create ()) // or Moshi, Kotlin Serialization .client (okHttpClient) .build() // Step 3 — Create the implementation (just a line) val userApi: UserApi = retrofit. create (UserApi::class.java) // Step 4 — Call it like a normal function val user = userApi.getUser ("123") // suspend — runs on IO thread // How it works under the hood: // retrofit.create() returns a Proxy object // Each method call → reads annotations → builds an HTTP Request // OkHttp executes the request → response parsed by Converter → your data type
Retrofit is a type-safe HTTP client for Android that turns a Kotlin interface into a fully functional API client. You define your endpoints as annotated interface methods — @GET("users/{id}") suspend fun getUser(@Path("id") id: String): UserResponse — and Retrofit generates the implementation at runtime. Type-safety means the compiler verifies that the return type matches what your converter can produce, and that annotations are used correctly. Mismatches are build errors or annotation processor errors, not runtime surprises.
Converters are what make Retrofit practical: they bridge the gap between raw HTTP response bytes and your Kotlin data classes. Adding .addConverterFactory(Json.asConverterFactory("application/json".toMediaType())) means every API method that returns a data class automatically gets deserialized from JSON. Retrofit supports multiple converters on one instance — the first one that claims to handle the content type wins. Protobuf, XML, and scalar types all have corresponding converter factories.
Retrofit wraps OkHttp: OkHttp handles the low-level HTTP mechanics — TCP connections, TLS handshakes, redirects, connection pooling — while Retrofit handles the higher-level mapping between your Kotlin types and HTTP semantics. The two are separate libraries by design, which means you configure OkHttp independently (interceptors, timeouts, cache) and hand the client to Retrofit's builder. Coroutine support is built in: annotate a method with suspend and Retrofit dispatches the call on OkHttp's thread pool, never blocking the calling thread.
The simplest mental model: "Retrofit turns an interface into a working HTTP client. I describe what I want (GET /users/123), Retrofit figures out how to do it." The key benefit over raw OkHttp: no string-building, no manual JSON parsing — the compiler validates everything.
2. What is OkHttp? How does it relate to Retrofit?
OkHttp is the actual HTTP engine — it makes the TCP connection, sends bytes, receives bytes. Retrofit sits on top and adds type safety. Think of OkHttp as the car engine, and Retrofit as the dashboard you interact with.
// OkHttp — the HTTP plumbing val client = OkHttpClient.Builder() .connectTimeout (30, TimeUnit.SECONDS) .readTimeout (30, TimeUnit.SECONDS) .writeTimeout (30, TimeUnit.SECONDS) .addInterceptor (HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY // logs full request/response }) .build () // Retrofit uses OkHttp internally val retrofit = Retrofit.Builder() .baseUrl ("https://api.example.com/") .client (client) // give Retrofit your configured OkHttp client .addConverterFactory (GsonConverterFactory.create ()) .build () // OkHttp's key features: // ✅ Connection pooling — reuses TCP connections (faster) // ✅ HTTP/2 support — multiplex multiple requests on one connection // ✅ GZIP compression — automatic response decompression // ✅ Caching — disk cache for GET responses // ✅ Interceptors — modify requests/responses in a pipeline // ✅ Transparent retry — retries on network glitches // Using OkHttp directly (without Retrofit) val request = Request.Builder().url ("https://api.example.com/users").build () val response = client.newCall (request).execute () // blocking val body = response.body?.string() // raw JSON string — no auto-parsing
OkHttp and Retrofit operate at different levels of abstraction. OkHttp is a complete HTTP client that handles the raw protocol: DNS resolution, TCP connections, TLS negotiation, HTTP/1.1 and HTTP/2 multiplexing, connection pooling, response caching, and redirects. It works with raw requests and responses — you build a Request object, execute it, and parse the response body yourself. OkHttp is the engine.
Retrofit is the ergonomic layer on top. It converts an annotated Kotlin interface into API calls backed by OkHttp, handles serialization and deserialization via converter factories, and integrates with Kotlin coroutines so your API methods are ordinary suspend functions. You cannot use Retrofit without OkHttp — it requires an OkHttpClient as its transport. You can use OkHttp without Retrofit, and sometimes should: streaming large file downloads, WebSocket connections, or cases where you want fine-grained control over the request lifecycle.
The practical implication is that configuration lives in two places. OkHttp handles cross-cutting concerns: auth interceptors, logging, timeouts, SSL pinning, cache. Retrofit handles API-specific concerns: base URL, converter factories, call adapter factories. Most importantly, the OkHttpClient should be a singleton — it owns a connection pool and a thread pool. Creating a new client per API call or per Retrofit instance wastes resources and defeats connection pooling. One client shared across the entire app, injected as a @Singleton via Hilt, is the correct architecture.
"OkHttp is the car engine, Retrofit is the steering wheel." You always configure OkHttp first (interceptors, timeouts, certificates), then hand that client to Retrofit. They're both from Square, designed to work together perfectly.
3. What is an OkHttp Interceptor? What are the two types and when do you use each?
An interceptor is a middleware that sits in the HTTP request/response pipeline. Every request flows through your interceptors before reaching the server, and every response flows back through them. Two types: Application interceptors and Network interceptors.
// Interceptor pipeline: App → [app interceptors] → OkHttp → [network interceptors] → Server // APPLICATION INTERCEPTOR — runs once, before caching // Best for: auth headers, logging, request modification class AuthInterceptor(private val tokenProvider: () -> String?) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val token = tokenProvider() val request = if (token != null) { chain.request().newBuilder () .header ("Authorization", "Bearer $token") .build () } else chain.request() return chain.proceed (request) } } // NETWORK INTERCEPTOR — runs on the network layer, after caching // Best for: response modification, retry logic, cache control class CacheControlInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val response = chain.proceed (chain.request()) return response.newBuilder () .header ("Cache-Control", "max-age=60") // cache for 60 seconds .build () } } // Register them on OkHttpClient val client = OkHttpClient.Builder() .addInterceptor (AuthInterceptor { tokenStore.token }) // application .addNetworkInterceptor (CacheControlInterceptor()) // network .addInterceptor (HttpLoggingInterceptor()) // application (logging) .build () // KEY DIFFERENCE: // addInterceptor() → application — runs ONCE, even for cached responses // addNetworkInterceptor() → network — skipped if response is cached
OkHttp interceptors sit in the request/response pipeline and can read, modify, or short-circuit calls. Application interceptors run first, before OkHttp's caching layer: they see every call including ones that will be served from cache, run exactly once per logical call, and are the right place for auth headers, logging, and retry logic. Network interceptors run only when a real network request is made — after the cache check, but before the bytes hit the wire. They are appropriate for modifying response headers or tracking raw network metrics.
chain.proceed(request) is the mechanism that passes the request to the next interceptor (or to the network). You must call it exactly once in most interceptors — forgetting it drops the request, calling it twice sends it twice. The pattern is: create a (possibly modified) Request, call chain.proceed(request) to get the Response, inspect or modify the response, and return it. For retry logic, you call chain.proceed again in a loop after closing the previous response.
Interceptor order matters because they form a chain. If your auth interceptor runs before your logging interceptor, the log includes the Authorization header. If it runs after, it does not. The retry interceptor should run before the auth interceptor so that re-authentication has a chance to succeed before the retry is abandoned. In practice: add interceptors in the order they should execute on the request, remembering that responses traverse the chain in reverse order — the last-added interceptor's response processing runs first. Keep each interceptor focused on a single responsibility to make the chain readable and each interceptor independently testable.
Simple rule: "Auth header and logging → application interceptor. Caching, retries, redirect handling → network interceptor." Most production apps only use application interceptors. The most important interceptor is always the auth one.
4. How do you handle API errors in Retrofit? What's the difference between a network error and an HTTP error?
There are two completely different kinds of failures: the request never reached the server (network error), or the server responded but with a non-2xx status (HTTP error). Each needs different handling.
// NETWORK ERROR — IOException — no response from server at all // Causes: no internet, DNS failure, timeout, server unreachable // HTTP ERROR — HttpException — server responded with 4xx or 5xx // Causes: 401 Unauthorized, 404 Not Found, 500 Server Error // Pattern 1: try-catch (simple, clear) suspend fun getUser(id: String): Result<User> { return try { Result.success (api.getUser (id)) } catch (e: HttpException) { // Server responded with error code val errorBody = e.response()?.errorBody()?.string() Result.failure (ApiException(e.code(), errorBody)) } catch (e: IOException) { // No internet or timeout Result.failure (NetworkException("No internet connection")) } } // Pattern 2: safeApiCall wrapper (reusable across all repos) sealed class ApiResult<out T> { data class Success<T>(val data: T) : ApiResult<T>() data class HttpError(val code: Int, val message: String?) : ApiResult<Nothing>() data class NetworkError(val cause: Throwable) : ApiResult<Nothing>() } suspend fun <T> safeApiCall(call: suspend () -> T): ApiResult<T> = try { ApiResult.Success (call ()) } catch (e: HttpException) { ApiResult.HttpError (e.code(), e.response()?.errorBody()?.string()) } catch (e: IOException) { ApiResult.NetworkError (e) } // Usage — exhaustive when forces handling all cases when (val result =safeApiCall { api.getUser (id) }) { is ApiResult.Success ->showUser (result.data) is ApiResult.HttpError ->showError ("Server error ${result.code}") is ApiResult.NetworkError ->showError ("No internet") }
Retrofit throws two distinct exception types. IOException means no response was received: no internet connection, DNS failure, TCP timeout, or socket reset. The server never saw the request or never responded. HttpException means the server responded with a non-2xx status code — 400, 401, 403, 404, 500. These are structurally different failures: an IOException may be retried, while an HttpException usually carries structured error information in the response body that tells you specifically what went wrong.
Raw exceptions should never reach the ViewModel. The repository is responsible for catching network exceptions and converting them to domain results. A sealed class NetworkResult<T> with Success(data: T), HttpError(code: Int, message: String), and NetworkError(cause: IOException) subclasses gives the ViewModel a typed, exhaustive set of outcomes to handle. The errorBody() from HttpException contains the server's error payload — typically JSON with a machine-readable error code and human-readable message. Parse it with your serialization library to extract the details.
A safeApiCall wrapper function centralises the try-catch pattern that every repository method would otherwise duplicate: suspend fun <T> safeApiCall(call: suspend () -> T): NetworkResult<T> = try { NetworkResult.Success(call()) } catch (e: HttpException) { NetworkResult.HttpError(e.code(), parseError(e.errorBody())) } catch (e: IOException) { NetworkResult.NetworkError(e) }. Every repository uses this wrapper: return safeApiCall { api.getUser(id) }. Error handling is consistent across all API calls without repetition, and the repository's caller always receives a typed result without unchecked exceptions.
Always distinguish the two: "IOException means we never heard from the server — show a 'Check your internet' message. HttpException means we heard from the server but it said no — show the specific error like 'Item not found' for 404." Users need different messages for each.
5. What is a Bearer token and how do you add authentication to every request?
A Bearer token is a string (usually a JWT) that proves the user is logged in. You send it in every request's Authorization header. An OkHttp interceptor is the cleanest way to add it automatically to every single request.
// The token looks like: "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIxMjMifQ..." // TokenManager — stores the current token (injected via Hilt @Singleton) class TokenManager @Inject constructor( private val prefs: EncryptedSharedPreferences ) { fun getAccessToken(): String? = prefs.getString ("access_token", null) fun saveTokens(access: String, refresh: String) { prefs.edit ().putString ("access_token", access) .putString ("refresh_token", refresh).apply () } } // Auth interceptor — adds token to every request class AuthInterceptor @Inject constructor( private val tokenManager: dagger.Lazy<TokenManager> ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val token = tokenManager.get ().getAccessToken () val request = chain.request().newBuilder () .apply { if (token != null)header ("Authorization", "Bearer $token") } .build () return chain.proceed (request) } } // Wire it up in Hilt module @Provides @Singleton fun provideOkHttp(authInterceptor: AuthInterceptor): OkHttpClient = OkHttpClient.Builder() .addInterceptor (authInterceptor) .build () // For public endpoints (login, signup) — skip the token // Option: check request URL and skip adding header for "/auth/*" paths if (!chain.request().url.encodedPath.startsWith ("/auth")) { // only add header for non-auth endpoints }
Bearer tokens are the standard mechanism for authenticating API requests: the client sends Authorization: Bearer <token> as a request header, and the server uses it to identify and authorise the user. The interceptor pattern is the right place to add auth headers: a single AuthInterceptor added to the OkHttpClient adds the header to every outbound request automatically. No endpoint annotation is needed, and no repository has to remember to add the header. If the token changes — after a refresh — the interceptor reads the new token from TokenManager on each request, not from a cached field.
Token storage security is non-negotiable. Plain SharedPreferences stores data in a world-readable XML file on non-rooted devices (and trivially accessible on rooted ones). EncryptedSharedPreferences wraps the file with AES-256-GCM encryption using a key stored in the Android Keystore — the key never exists in plaintext in the application process. For access tokens and especially refresh tokens, which grant the ability to mint new access tokens, EncryptedSharedPreferences is the minimum acceptable storage. Alternatively, store tokens in memory during the session and only persist the refresh token to encrypted storage.
Public endpoints — login, signup, password reset — must not have the Authorization header added. The interceptor should check the request URL: if it matches a public endpoint pattern, skip the header. A clean approach is to use a custom annotation on the Retrofit interface method that the interceptor checks via request.tag(Invocation::class.java). The circular dependency problem — OkHttpClient needs AuthInterceptor needs TokenManager needs a Retrofit backed by OkHttpClient — is broken with dagger.Lazy<TokenManager>, deferring TokenManager's construction until the first request is made.
"I store tokens in EncryptedSharedPreferences, read them in an AuthInterceptor, and add the Authorization header there. This means zero token management code in my repositories — they just call the API and the token is added automatically. Every new API endpoint gets auth for free."
6. How do you implement token refresh — automatically refreshing an expired JWT without the user noticing?
When the access token expires, the server returns 401. A smart interceptor catches that 401, silently fetches a new token using the refresh token, and retries the original request — the user sees nothing.
// The flow: // Request → 401 Unauthorized → refresh token → get new access token → retry original request class TokenRefreshInterceptor @Inject constructor( private val tokenManager: TokenManager, private val authApi: dagger.Lazy<AuthApi> // Lazy to avoid circular dep ) : Interceptor { private val mutex = Mutex() // prevent multiple simultaneous refresh calls override fun intercept(chain: Interceptor.Chain): Response { val response = chain.proceed (chain.request()) // Not a token expiry — return response immediately if (response.code != 401) return response response.close () // ← IMPORTANT: must close before retrying // Refresh using runBlocking (interceptors are blocking by design) val newToken =runBlocking { mutex.withLock { // only one coroutine refreshes at a time // If another coroutine already refreshed, use its token val existing = tokenManager.getAccessToken () if (existing != null && existing != chain.request().header ("Authorization")) { return@withLock existing // token was already refreshed by another call } // Fetch fresh tokens val refreshToken = tokenManager.getRefreshToken () ?: return@withLock null val tokens = authApi.get ().refreshToken (RefreshRequest(refreshToken)) tokenManager.saveTokens (tokens.accessToken, tokens.refreshToken) tokens.accessToken } } if (newToken == null) { tokenManager.clearTokens () // refresh failed — force logout return chain.proceed (chain.request()) // will get 401 again → logout triggered } // Retry original request with new token val newRequest = chain.request().newBuilder () .header ("Authorization", "Bearer $newToken") .build () return chain.proceed (newRequest) } }
The 401 token refresh interceptor handles the access token expiry lifecycle. When a request returns 401, the access token has expired. The interceptor refreshes the token using the refresh token, then retries the original request with the new access token. This happens transparently from the perspective of all callers — they issued one request, they receive one response, and the token refresh was an invisible implementation detail of the network layer.
The thundering herd problem requires a Mutex. In an app with active background sync, ten coroutines might all be making API requests when the access token expires simultaneously. All ten receive 401, and all ten attempt to refresh the token. Without a mutex, you make ten refresh calls, receive ten new tokens (some backends invalidate previous tokens on each refresh, logging out other sessions), and introduce a race condition on which token gets stored last. With a Mutex: the first coroutine to acquire the lock performs the refresh, stores the new token, and releases the lock. The other nine acquire the lock in turn, read the updated token, see it is newer than the 401 response, and retry with the cached new token — zero additional refresh calls.
Two implementation details are easy to miss. First, response.close() must be called on the 401 response before making any further requests. OkHttp's connection pool tracks responses as "in use" until closed. Not closing the 401 response before the retry leaks the connection — the connection pool eventually exhausts, and new requests hang. Second, if the token refresh itself returns 401 or 403, the refresh token has expired. At this point the interceptor must clear all stored tokens and navigate the user to the login screen. Retrying a failed refresh is pointless and creates an infinite loop.
The Mutex is the key insight interviewers look for. "If the user has 10 parallel API calls and all get 401 simultaneously, without a Mutex you'd make 10 refresh calls. With Mutex, only 1 refreshes — the other 9 wait and automatically use the new token." This shows you understand concurrency in networking.
7. Which JSON library should you use with Retrofit in 2025 — Gson, Moshi, or Kotlin Serialization?
Kotlin Serialization is the 2025 recommendation — it's Kotlin-first, works with Kotlin Multiplatform, and is fast. Moshi is a great second choice. Gson is legacy — it has null-safety issues with Kotlin.
// ✅ BEST: Kotlin Serialization (2025 recommendation) // build.gradle.kts: // plugins { kotlin("plugin.serialization") } // implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0") // implementation("com.jakewharton.retrofit2:retrofit2-kotlinx-serialization-converter:1.0.0") @Serializable data class User( val id: String, val name: String, @SerialName("created_at") val createdAt: String // maps snake_case → camelCase ) val retrofit = Retrofit.Builder() .addConverterFactory (Json.asConverterFactory ("application/json".toMediaType ())) .build () // ✅ GOOD: Moshi — Kotlin-aware, null-safe data class User( val id: String, @Json(name = "created_at") val createdAt: String ) val moshi = Moshi.Builder().add (KotlinJsonAdapterFactory()).build () // ❌ AVOID: Gson — has Kotlin null-safety issues // Gson uses Java reflection — doesn't respect Kotlin's non-null types // This compiles but CRASHES at runtime: data class User(val name: String) // non-null // If API returns: {"name": null} // Gson sets name = null anyway → NPE when you use it // Kotlin Serialization / Moshi: throws an exception immediately ✅ (fail fast) // Comparison: // Gson Moshi KotlinX Serialization // Kotlin-aware ❌ ✅ ✅ // KMP support ❌ ❌ ✅ // Code generation ❌ ✅ ✅ // Null-safety ❌ ✅ ✅ // Google maintains ❌ ❌ ✅
In 2025, Kotlin Serialization (kotlinx.serialization) is the default choice for new Android projects. It is developed by JetBrains alongside the Kotlin language, generates serialization code at compile time via a compiler plugin, and respects Kotlin's null safety — a non-nullable String field that receives null in the JSON throws a deserialization exception rather than silently assigning null to a non-nullable type. It supports Kotlin Multiplatform, making it the only choice if your models live in shared code that compiles to iOS or other targets.
Moshi is a solid alternative, particularly for projects already using it. It is Kotlin-aware (with the Kotlin adapter), supports code generation via moshi-kotlin-codegen, and correctly handles nullable vs non-nullable Kotlin types. Its API surface is smaller than Gson and its error messages are clearer. The main reason not to start a new project with Moshi is that Kotlin Serialization covers the same ground with better Kotlin integration and KMP support. Gson is the legacy option: it uses reflection, does not understand Kotlin's nullability model (it happily assigns null to a non-nullable String), and can cause NullPointerException at runtime in code that the compiler claimed was null-safe. New projects should not use Gson.
API field name mapping is handled with library-specific annotations: @SerialName("user_id") for Kotlin Serialization, @Json(name = "user_id") for Moshi, @SerializedName("user_id") for Gson. These map snake_case JSON fields to camelCase Kotlin properties without altering the domain model's naming conventions. ignoreUnknownKeys = true (Kotlin Serialization) or a corresponding Moshi adapter setting is essential in production: it prevents old app versions from crashing when the backend adds new fields to responses, which happens continuously as the API evolves.
"I use Kotlin Serialization in all new projects — it's Kotlin-native, works with KMP, and uses code generation so there's no reflection overhead. Gson is the classic 'gotcha' interview topic: if the API sends null for a non-null Kotlin field, Gson sets it to null anyway and your app crashes later in a confusing place."
8. How does OkHttp caching work? How do you implement offline support using the HTTP cache?
OkHttp has a built-in disk cache that stores GET responses. When offline, you can force OkHttp to serve stale cached data instead of failing with a network error — giving users something to look at.
// Set up OkHttp cache — 10MB on disk val cacheDir = File(context.cacheDir, "http_cache") val cache = Cache(cacheDir, 10 * 1024 * 1024) // 10 MB val client = OkHttpClient.Builder() .cache (cache) .addInterceptor (offlineCacheInterceptor (context)) .addNetworkInterceptor (onlineCacheInterceptor ()) .build () // ONLINE: tell OkHttp to cache responses for 5 minutes fun onlineCacheInterceptor() = Interceptor { chain -> chain.proceed (chain.request()).newBuilder () .header ("Cache-Control", "public, max-age=300") // 5 min cache .build () } // OFFLINE: when no internet, use cached data up to 7 days old fun offlineCacheInterceptor(context: Context) = Interceptor { chain -> var request = chain.request() if (!context.isConnected ()) { request = request.newBuilder () .header ("Cache-Control", "public, only-if-cached, max-stale=604800") .build () // use cache even if 7 days stale } chain.proceed (request) } // Helper fun Context.isConnected (): Boolean { val cm =getSystemService (ConnectivityManager::class.java) return cm.activeNetwork != null } // Server must send Cache-Control headers for this to work! // "Cache-Control: no-cache" → OkHttp won't cache // "Cache-Control: max-age=60" → cache for 60 seconds
OkHttp's built-in cache stores GET responses to disk and serves them according to HTTP caching semantics. Configure it with OkHttpClient.Builder().cache(Cache(cacheDir, maxSizeBytes)). When a server response includes Cache-Control: max-age=300, OkHttp serves that response from disk for five minutes without hitting the network. After expiry, OkHttp sends a conditional request (If-None-Match or If-Modified-Since) — if the server responds 304 Not Modified, the cached response is reused and only the headers are transferred, saving bandwidth.
Offline scenarios require different directives. Cache-Control: max-stale=86400 tells OkHttp to accept a cached response up to one day old even if it is past its max-age. Cache-Control: only-if-cached instructs OkHttp to only use the cache — if no cached response exists, it returns a 504 rather than attempting a network request. This is useful in offline-first apps: detect connectivity state and add only-if-cached when offline so API calls serve stale data rather than throwing network exceptions. Wrap the 504 response gracefully in your error handling layer.
Server cooperation is the practical limiting factor. OkHttp's caching respects HTTP cache headers, but many APIs return Cache-Control: no-cache or no-store, or return no cache headers at all. A network interceptor can override headers on responses: response.newBuilder().header("Cache-Control", "max-age=60").build() forces a one-minute cache even for APIs that did not opt in. This should be used carefully — you are overriding the server's declared caching policy, which may be intentional — but it is the only option for third-party APIs you cannot modify. The cache also only works for GET requests; POST, PUT, PATCH, and DELETE are never cached.
OkHttp cache is great for simple offline support but not for full offline-first apps. "For product listings or news I use OkHttp cache — quick to implement. For anything user-specific (cart, orders) I use Room as the source of truth — OkHttp cache doesn't survive app restarts reliably."
9. Scenario: Your API returns different response shapes for success and error. How do you model this in Retrofit?
The trick is using Retrofit's Response<T> wrapper which gives you access to both the success body and the raw error body — letting you parse the error into your error model even on non-2xx responses.
// API returns: 200 → { "user": {...} } // API returns: 400 → { "error": "Invalid email", "code": "INVALID_EMAIL" } // Error response model @Serializable data class ApiError(val error: String, val code: String) // Use Response<T> wrapper in Retrofit interface interface UserApi { @POST("users/register") suspend fun register(@Body req: RegisterRequest): Response<User> // Response<T> never throws — even 4xx/5xx come back as a Response object } // In repository — parse both success and error suspend fun register(email: String, password: String): ApiResult<User> { val response = api.register (RegisterRequest(email, password)) return if (response.isSuccessful ()) { val body = response.body() if (body != null) ApiResult.Success (body) else ApiResult.HttpError (200, "Empty response body") } else { // Parse error body into ApiError val errorJson = response.errorBody()?.string() val apiError = try { Json.decodeFromString <ApiError>(errorJson ?: "") } catch (e: Exception) { null } ApiResult.HttpError (response.code(), apiError?.error ?: "Unknown error") } } // ViewModel uses the result cleanly when (val result = userRepo.register (email, pass)) { is ApiResult.Success ->navigateToHome () is ApiResult.HttpError ->showError (result.message) // "Invalid email" else ->showError ("Network error") }
When a Retrofit method returns T directly, Retrofit throws HttpException for any non-2xx response. When it returns Response<T>, Retrofit never throws for HTTP errors — the Response object wraps all outcomes including 4xx and 5xx, and you inspect the response code yourself. The choice depends on your error handling architecture: T is cleaner for simple use cases where all errors are handled uniformly, while Response<T> is necessary when you need to parse the error body for structured error information from the server.
response.isSuccessful() returns true for status codes 200–299. For all other codes, response.body() returns null — always. Accessing body() without checking isSuccessful() first is a guaranteed NullPointerException. The pattern is: if (response.isSuccessful()) { val data = response.body()!! } else { val error = response.errorBody() }. The non-null assertion on body() is safe only inside the isSuccessful() branch.
Parsing errorBody() requires extra care. The error body is an ResponseBody that can only be read once — if you read it in an interceptor, it is consumed and unavailable in the repository. The standard approach is to convert it to a string first with errorBody()?.string() and then parse with your serialization library. Many APIs return a consistent error schema — {"error": "INVALID_TOKEN", "message": "Token has expired"} — which you can map to a sealed class of API error types. This rich error information lets the ViewModel display specific error messages or take specific actions based on the error type rather than only knowing the HTTP status code.
"I use Response<T> when the API has rich error responses I need to show the user. It never throws — I check isSuccessful(), parse errorBody() on failure. For simple endpoints where I just need the data or an error message, the suspend fun without Response and a safeApiCall wrapper is cleaner."
10. Scenario: How do you implement SSL pinning to prevent man-in-the-middle attacks on your API?
SSL pinning ties your app to a specific server certificate or public key -- even a legitimate CA-issued certificate for the same domain will be rejected if it doesn't match the pinned value. This prevents man-in-the-middle attacks where an attacker intercepts traffic using a certificate from a compromised CA.
// CertificatePinner -- pin to specific certificate public key hash val pinner = CertificatePinner.Builder() .add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") .add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // backup pin .build() val client = OkHttpClient.Builder() .certificatePinner(pinner) .build() // Network Security Config (XML) -- declarative alternative // res/xml/network_security_config.xml // <network-security-config><domain-config><pin-set><pin digest="SHA-256">...</pin> // Get the hash for your certificate: // openssl s_client -connect api.example.com:443 | openssl x509 -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256 -binary | base64
SSL pinning is a defence against certificate authority compromise and MITM attacks. Normally, Android trusts any certificate signed by a CA in the system store — a malicious or compromised CA could issue a valid certificate for your domain, and the device would trust it. Pinning means your app hardcodes the expected certificate public key hash. Even if a valid CA-signed certificate is presented for your domain, if its hash does not match the pin, OkHttp rejects the connection with an SSLPeerUnverifiedException. Configure it with OkHttpClient.Builder().certificatePinner(CertificatePinner.Builder().add("api.example.com", "sha256/HASH").build()).
Always pin at least two certificates: the current leaf or intermediate certificate and a backup. Pinning only the current certificate is an operational trap: when the certificate expires and is renewed, the new certificate has a different hash. Every app that has not yet updated will immediately fail all API requests for all users — a self-induced outage with no server-side fix. The backup pin should be a different certificate already generated and ready to deploy. When you roll to the new certificate, the backup becomes the current pin, and you generate a new backup. This gives you a renewal window without forcing a rushed app update.
CertificatePinner in OkHttp applies per client — precise and controllable, but requires code deployment to change. Network Security Config (res/xml/network_security_config.xml) applies at the OS level for the entire app, is declarative XML, and supports per-domain pinning with backup pins and override dates. NSC is the recommended approach for new projects because it separates security policy from code. Testing pinning is straightforward: enable a proxy tool like Charles or mitmproxy (which installs its own certificate), attempt a request, and verify your app refuses it. A correctly pinned app will throw the SSL exception rather than returning a response through the proxy.
"SSL pinning is powerful but dangerous without planning. I always pin two hashes — current and a pre-generated backup. And I use Network Security Config with an expiration date so if something goes wrong, old pins auto-expire. Also, pin the intermediate CA hash instead of the leaf cert — intermediates change far less often."
11. What is the difference between @Query, @Path, @Body, and @Header in Retrofit?
Each annotation controls where your parameter ends up in the HTTP request. Understanding this lets you call any REST API correctly without guessing.
interface ProductApi { // @Path — replaces a placeholder in the URL // Result: GET /products/42 @GET("products/{id}") suspend fun getProduct(@Path("id") productId: Int): Product // @Query — appended as URL query parameters // Result: GET /products?page=2&limit=20&category=shoes @GET("products") suspend fun getProducts( @Query("page") page: Int = 1, @Query("limit") limit: Int = 20, @Query("category") category: String? = null // null = omitted from URL ): List<Product> // @Body — serialised as JSON in the request body // Result: POST /products with JSON body @POST("products") suspend fun createProduct(@Body product: CreateProductRequest): Product // @Header — adds a single request header // Result: GET /products with "X-Store-Id: 99" header @GET("products") suspend fun getStoreProducts(@Header("X-Store-Id") storeId: Int): List<Product> // @QueryMap — dynamic set of query params @GET("products/search") suspend fun search(@QueryMap filters: Map<String, String>): List<Product> // @FormUrlEncoded + @Field — form submission @FormUrlEncoded @POST("login") suspend fun login(@Field("email") email: String, @Field("password") pw: String): AuthResponse }
Retrofit annotations map Kotlin method parameters to HTTP request components. @Path("id") substitutes a named placeholder in the URL path: @GET("users/{id}") with @Path("id") userId: String produces GET /users/42. Use @Path for resource identifiers — values that identify the specific resource being acted upon. @Query("sort") appends a query parameter: ?sort=asc. Use @Query for options that filter, sort, paginate, or otherwise modify the set of resources returned.
@Body serializes a Kotlin object to the request body using the installed converter factory. A data class CreateUserRequest(val name: String, val email: String) passed as @Body request: CreateUserRequest becomes the JSON request body for POST and PUT requests. Never use @Body with GET or DELETE — these methods have no request body by HTTP specification. @Header("X-Custom-Header") adds a per-request header. This is for headers that vary per call — a per-request idempotency key, an API version override. Global headers that are the same for every request — auth, User-Agent — belong in an interceptor, not repeated as annotations on every endpoint.
@QueryMap accepts a Map<String, String> and appends all entries as query parameters. This is invaluable for search and filter endpoints where the set of parameters is determined at runtime. A product search might accept combinations of category, price range, brand, colour, and availability — building these as individual @Query parameters would produce a method with dozens of optional parameters. @QueryMap lets the caller construct only the relevant parameters and pass them as a map. Retrofit omits null values in a @QueryMap automatically, so absent parameters do not appear in the URL.
Memory trick: Path = in the URL road. Query = after the ? question mark. Body = in the package/envelope. Header = on the envelope label. The most common mistake: putting filtering parameters as @Path instead of @Query — that changes the URL structure.
12. What is REST? What are the key HTTP methods and when do you use each?
REST (Representational State Transfer) is an architectural style for APIs. Resources (things) are identified by URLs. HTTP methods (verbs) describe what action to perform on that resource. Retrofit annotations map directly to these.
// REST — think of URLs as nouns and HTTP methods as verbs // URL: /users/123 = "the user with id 123" interface RestApi { // GET — READ data (safe, idempotent, cacheable) @GET("users") suspend fun getAllUsers(): List<User> @GET("users/{id}") suspend fun getUser(@Path("id") id: String): User // POST — CREATE a new resource @POST("users") suspend fun createUser(@Body user: CreateUserRequest): User // PUT — REPLACE entire resource (idempotent) @PUT("users/{id}") suspend fun replaceUser(@Path("id") id: String, @Body user: User): User // PATCH — UPDATE part of a resource @PATCH("users/{id}") suspend fun updateUser(@Path("id") id: String, @Body update: UserUpdate): User // DELETE — REMOVE a resource (idempotent) @DELETE("users/{id}") suspend fun deleteUser(@Path("id") id: String) } // Key concepts: // Idempotent: calling it multiple times = same result as calling once // GET/PUT/DELETE are idempotent; POST is NOT // "GET /orders" twice → same list (no side effects) // "POST /orders" twice → two separate orders created! // HTTP Status Codes to know: // 200 OK — success with body // 201 Created — POST succeeded, resource created // 204 No Content — success, no body (DELETE) // 400 Bad Request — client sent invalid data // 401 Unauthorized — not authenticated (no/invalid token) // 403 Forbidden — authenticated but not authorised // 404 Not Found — resource doesn't exist // 500 Server Error — server crashed
HTTP method semantics matter for correctness, caching, and client retry logic. GET is safe (no side effects) and idempotent (calling N times = same as calling once), which is why browsers and OkHttp can cache GET responses. GET never carries a request body. POST creates a resource or triggers an action — it is not idempotent, which means two identical POST requests may create two separate resources. This is why retry logic for POST requires idempotency keys: you need the server to deduplicate, not the client to avoid retrying.
PUT replaces a resource completely. If you PUT a user resource and omit the phone number field, the server replaces the entire resource with what you sent — the phone number is deleted. PUT is idempotent: sending the same PUT twice produces the same final state. PATCH applies a partial update — only the fields you include are changed, the rest remain as-is. PATCH is the correct method for a "save changes" operation in a form where the user only edits some fields. Sending a PATCH with just {"name": "Alice"} changes the name without affecting any other field.
DELETE removes a resource and is idempotent: deleting a resource that does not exist (because it was already deleted) returns 404 or 204, but the state of the world is the same — the resource is gone. This allows DELETE to be retried safely after a timeout, unlike POST. The practical implication for Android development is that you should match the HTTP method to the semantic intent: use GET for reads, POST for creates and non-idempotent actions, PUT for full replacements, PATCH for partial updates, and DELETE for removals. Mismatching — using POST for everything, for example — sacrifices HTTP's built-in semantics and makes retry logic, caching, and API documentation harder.
The idempotency question is a common trap. "POST /payments twice → charged twice. PUT /payments/123 twice → same payment updated twice (same result). This is why payment APIs use POST with a unique idempotency key — the server deduplicates based on that key, preventing double charges."
13. REST vs GraphQL — what are they, how do they differ, and when do you choose each?
REST (Representational State Transfer) is a URL-based API style where each endpoint represents a resource and HTTP verbs (GET, POST, PUT, DELETE) define operations. GraphQL is a query language where the client specifies exactly which fields it needs in a single request. REST is simpler to cache and easier to debug; GraphQL eliminates over-fetching and the n+1 problem.
// REST -- multiple round trips to assemble one screen val user = api.getUser(id) // GET /users/{id} val orders = api.getOrders(id) // GET /orders?userId={id} val prefs = api.getPrefs(id) // GET /preferences/{id} // GraphQL -- one request, client specifies exactly which fields val query = """ query GetDashboard(\$userId: ID!) { user(id: \$userId) { name email } orders(userId: \$userId) { id total status } preferences(userId: \$userId) { theme notifications } } """ // Apollo Kotlin client: val response = apolloClient.query(GetDashboardQuery(userId = id)).execute()
REST and GraphQL solve the data-fetching problem differently. REST maps resources to URLs: GET /users/123 returns the user resource, typically with every field the server deems relevant. A profile screen that only needs name and avatar receives all 40 fields — over-fetching. Mobile over-fetching wastes bandwidth and serialization time, which matters on constrained connections. GraphQL lets the client specify exactly the fields it needs in the query: the profile screen requests only { user { name avatar } } and receives only those two fields. Every screen can be served exactly its data requirements without server changes.
The n+1 problem is REST's other structural weakness for mobile. Displaying a list of 20 orders, each with the orderer's name, requires one request to fetch the order list, then 20 requests to fetch each user's details — 21 round trips. GraphQL resolves this in a single query: the client requests orders with their nested user data, and the server's resolvers fetch all users in a batched call before returning. One HTTP round trip instead of 21. REST can address this with custom endpoints that aggregate data, but this requires server changes per mobile screen, which GraphQL avoids by design.
REST's advantages are equally real. HTTP caching works natively — GET responses are cached by URL, which works perfectly for REST's resource-per-URL model. GraphQL uses POST for all queries, which is not cached by default. REST APIs are debuggable with curl, browser address bar, or any HTTP tool. GraphQL requires understanding the query language and tooling like Apollo Playground. The decision framework: choose REST for simple CRUD APIs, public APIs where broad client compatibility matters, or teams without GraphQL experience. Choose GraphQL for complex screens that aggregate data from many sources, high-traffic mobile apps where bandwidth efficiency matters, and teams already proficient with the Apollo ecosystem.
"REST is simpler and better supported by caching infrastructure. GraphQL shines when different clients need different data shapes from the same API — mobile needs 3 fields, web needs 15. Instead of maintaining two REST endpoints, one GraphQL schema serves both. For a startup I'd default to REST; for a mature platform with many clients, GraphQL makes more sense."
14. Scenario: Implement a robust retry mechanism with exponential backoff for flaky network requests.
Exponential backoff means waiting progressively longer between retries — 1s, 2s, 4s. This prevents hammering a struggling server, which would make things worse. Add jitter (random small delay) to prevent all clients retrying at the same moment.
// Approach 1: Repository-level retry with Kotlin Flow fun <T> retryWithBackoff( times: Int = 3, initialDelay: Long = 1_000, maxDelay: Long = 16_000, block: suspend () -> T ): T { var currentDelay = initialDelay repeat(times - 1) { attempt -> try { returnblock () } catch (e: IOException) { // Only retry on network errors, not HTTP errors Log.w ("Retry", "Attempt ${attempt + 1} failed: ${e.message}") } val jitter = (0..500).random () // random 0-500ms to spread retriesdelay (currentDelay + jitter) currentDelay =minOf (currentDelay * 2, maxDelay) // double but cap at max } returnblock () // last attempt — let exception propagate if it fails } // Usage in repository suspend fun syncData(): SyncResult =retryWithBackoff (times = 3) { api.sync () } // Approach 2: OkHttp interceptor (applies to all requests) class RetryInterceptor(private val maxRetries: Int = 3) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { var attempt = 0 while (true) { try { return chain.proceed (chain.request()) } catch (e: IOException) { if (++attempt >= maxRetries) throw e // exhausted — give up val waitMs = (1_000 * (attempt * attempt)).toLong() // 1s, 4s, 9s Thread.sleep (waitMs) // OkHttp interceptors are blocking } } } } // What to retry and what NOT to retry: // ✅ Retry: IOException (timeout, socket reset, server unreachable) // ✅ Retry: 500, 502, 503, 504 (server temporarily down) // ❌ Never retry: 400 Bad Request (wrong data — retrying won't help) // ❌ Never retry: 401 Unauthorized (need to refresh token first) // ❌ Never retry: 404 Not Found (resource doesn't exist)
Exponential backoff spaces retry attempts with increasing delays: 1 second, 2 seconds, 4 seconds, 8 seconds. The intuition is that a server that failed a request is under load or recovering — hammering it with immediate retries makes recovery harder. Spacing retries gives the server time to shed load, restart, or allow upstream dependencies to recover. The base formula is delay = baseDelay * 2^attemptNumber. A maximum delay cap — typically 30–60 seconds — prevents the delays from becoming uselessly long during extended outages.
Jitter is a crucial addition. Without jitter, all clients that received the same error at the same time will retry at the same time — a synchronized burst that creates a thundering herd, which can overwhelm the just-recovering server. Adding a random component to each delay — delay = baseDelay * 2^attempt + random(0, baseDelay) — spreads the retry attempts across time. The randomisation need not be large; even ±50% of the calculated delay is enough to desynchronize the clients. Full jitter (delaying a random amount between 0 and the full calculated delay) provides the best distribution.
Retry should only apply to transient failures. IOException (no response) and 5xx server errors are candidates — the client did nothing wrong and retrying may succeed. 4xx errors are not transient: a 400 Bad Request will be bad on every retry, a 401 needs reauthentication before retry (handled by the refresh interceptor), a 404 means the resource genuinely does not exist. Retrying 4xx creates unnecessary load and misleads the user. The implementation choice between an OkHttp interceptor (global, applies to all requests) and a repository-level retry (per operation, with operation-specific retry budget and error classification) depends on how uniform your retry policy needs to be.
Jitter is the key senior insight: "Without jitter, if 10,000 users all get a 503 at the same time, they all retry at t=1s, t=2s, t=4s simultaneously — creating a retry storm that makes the server problem worse. Random jitter spreads them out so the server can recover."
15. How do you use Kotlin Serialization with Retrofit? What are @SerialName and ignoreUnknownKeys?
Kotlin Serialization is the modern way to parse JSON in Kotlin — no reflection, compile-time safe, KMP compatible. A few configuration options make it production-ready.
// build.gradle.kts // plugins { kotlin("plugin.serialization") } // implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0") // implementation("com.jakewharton.retrofit2:retrofit2-kotlinx-serialization-converter:1.0.0") // Configure Json instance — do this ONCE in your DI module val json = Json { ignoreUnknownKeys = true // ← CRITICAL: don't crash if API adds new fields isLenient = true // accept slight variations in JSON format encodeDefaults = false // don't send null fields in request bodies coerceInputValues = true // coerce null to default value for non-null fields } // Wire into Retrofit val retrofit = Retrofit.Builder() .addConverterFactory (json.asConverterFactory ("application/json".toMediaType ())) .build () // Data classes — annotate with @Serializable @Serializable data class Product( val id: String, val name: String, @SerialName("base_price") val basePrice: Double, // API sends "base_price", Kotlin uses camelCase @SerialName("image_url") val imageUrl: String? = null, // optional field — defaults to null @SerialName("is_available") val isAvailable: Boolean = true // default value if field absent ) // Polymorphic types — different subclasses from same field @Serializable sealed class Event { @Serializable @SerialName("order") data class OrderEvent(val orderId: String) : Event() @Serializable @SerialName("payment") data class PaymentEvent(val amount: Double) : Event() }
@Serializable is the opt-in annotation that tells the Kotlin Serialization compiler plugin to generate serialization and deserialization code for a class. Every class that participates in JSON parsing must be annotated. The code generation happens at compile time — the generated serializers are Kotlin objects that know how to read and write each field, with no runtime reflection involved. This makes deserialization significantly faster than Gson's reflection-based approach, particularly for deeply nested objects, and means the serialization code is visible and debuggable.
@SerialName("user_id") maps a JSON field name to a differently-named Kotlin property. APIs commonly return snake_case JSON, while Kotlin conventions use camelCase. Without @SerialName, the property name in Kotlin must exactly match the JSON key — val user_id: String instead of val userId: String. You can also configure a global naming strategy on the Json instance with namingStrategy = JsonNamingStrategy.SnakeCase (available in recent versions), which automatically converts between the conventions without per-field annotations.
ignoreUnknownKeys = true is the most important production safety setting. APIs evolve constantly — new fields are added, existing fields get renamed, new nested objects appear. Without this setting, deserializing a response that contains a field your data class does not know about throws a SerializationException and crashes the app. This means every API change that adds a field breaks old app versions still in production. With ignoreUnknownKeys = true, unknown fields are silently skipped. Combine this with Kotlin default values on optional fields — val promoted: Boolean = false — so that fields the server might not always include do not cause missing-key exceptions.
"ignoreUnknownKeys = true is the most important production setting. Without it, your app crashes when the backend team adds a new field to the API response — and old app versions can't be updated. With it, new fields are silently ignored."
16. Scenario: How do you add logging to all your API requests in debug mode only — without sending any logs in production?
HttpLoggingInterceptor from OkHttp logs every request and response. The key: only add it when BuildConfig.DEBUG is true — that way production builds never log sensitive data.
// HttpLoggingInterceptor — logs full HTTP traffic // implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") @Module @InstallIn(SingletonComponent::class) object NetworkModule { @Provides @Singleton fun provideOkHttp(authInterceptor: AuthInterceptor): OkHttpClient = OkHttpClient.Builder() .addInterceptor (authInterceptor) // auth always .apply { if (BuildConfig.DEBUG) { // logging ONLY in debugaddInterceptor ( HttpLoggingInterceptor(HttpLoggingInterceptor.Logger.DEFAULT) .apply { level = HttpLoggingInterceptor.Level.BODY } ) } } .build () } // Log levels — choose based on what you need: // Level.NONE → nothing logged // Level.BASIC → "→ POST https://api.example.com/users (200 OK, 45ms)" // Level.HEADERS → above + all headers // Level.BODY → everything including request + response JSON // Production alternative — Timber with crash reporting val logger = HttpLoggingInterceptor { message -> Timber.tag("HTTP"). d (message) // routed through Timber (easy to disable) } // Sensitive data — redact auth tokens from logs class SanitisedLogger : HttpLoggingInterceptor.Logger { override fun log(message: String) { val sanitised = message.replace (Regex("Bearer [\\w.-]+"), "Bearer [REDACTED]") Timber.d(sanitised) } }
OkHttp's HttpLoggingInterceptor logs request and response details at configurable verbosity levels. Level.BASIC logs the request method, URL, and response code. Level.HEADERS adds request and response headers. Level.BODY adds the full request and response bodies — the most useful level during API integration work, revealing exactly what JSON is being sent and received. The interceptor should only be added in debug builds: if (BuildConfig.DEBUG) { addInterceptor(loggingInterceptor) }. BuildConfig.DEBUG is a compile-time constant — release builds have the entire branch dead-code eliminated by R8, so there is no logging overhead and no risk of accidental logging in production.
Never log at Level.BODY in production. API responses frequently contain personally identifiable information: user names, email addresses, phone numbers, location data, payment details. Logging this to Logcat or any remote logging service creates a privacy and compliance risk. Beyond PII, response bodies may contain bearer tokens in certain auth flows. A logging interceptor that runs in production with Level.HEADERS would log the Authorization: Bearer token header on every request — any crash reporter or log aggregator ingesting those logs now has access to user tokens.
When debug logging is genuinely needed beyond development — for example, in a staging build or for analytics — implement a custom Logger that sanitises output before logging. Redact the Authorization header value with a fixed placeholder: Authorization: Bearer [REDACTED]. Redact any response fields known to contain PII. Route the sanitised output through Timber: implement HttpLoggingInterceptor.Logger { message -> Timber.tag("OkHttp").d(message) }. This integrates OkHttp logging with your app's unified logging infrastructure, respects Timber's tree-based production vs debug log routing, and means log level changes are controlled in one place.
"The BuildConfig.DEBUG check is not just good practice — it's security. API responses can contain user data, access tokens, and PII. Logging those in production would be a data breach risk. In debug mode I use Level.BODY to see everything; in production, nothing is logged."
17. How do you upload files (images, documents) using Retrofit and Multipart?
File uploads use Multipart form data — the request body contains multiple "parts", each with its own headers and content. Retrofit makes this clean with @Multipart and @Part annotations.
// API interface for file upload interface UploadApi { @Multipart @POST("users/{id}/avatar") suspend fun uploadAvatar( @Path("id") userId: String, @Part avatar: MultipartBody.Part, // the file @Part("description") description: RequestBody // text field ): UploadResponse } // In your repository — convert file to MultipartBody.Part suspend fun uploadAvatar(userId: String, imageFile: File): UploadResponse { // Create request body from file val requestBody = imageFile.asRequestBody ("image/jpeg".toMediaType ()) // Wrap in MultipartBody.Part with field name "avatar" val avatarPart = MultipartBody.Part .createFormData ("avatar", imageFile.name, requestBody) // Text field alongside the file val descriptionBody = "Profile photo".toRequestBody ("text/plain".toMediaType ()) return api.uploadAvatar (userId, avatarPart, descriptionBody) } // Upload from URI (picked from gallery) suspend fun uploadFromUri(context: Context, uri: Uri): UploadResponse { val inputStream = context.contentResolver.openInputStream(uri)!! val bytes = inputStream. readBytes () inputStream.close () val requestBody = bytes.toRequestBody ("image/jpeg".toMediaType ()) val part = MultipartBody.Part.createFormData ("avatar", "photo.jpg", requestBody) return api.uploadAvatar (userId, part, "Profile photo".toRequestBody ()) } // For upload progress — use OkHttp's CountingRequestBody // Or use Firebase Storage / S3 pre-signed URLs for large files
File uploads in Retrofit use the @Multipart annotation, which sets the request content type to multipart/form-data. Each part of the multipart request is a separate @Part parameter. For file data, wrap the bytes in a MultipartBody.Part: MultipartBody.Part.createFormData("file", filename, requestBody). The RequestBody wraps the raw bytes with a MediaType: file.readBytes().toRequestBody("image/jpeg".toMediaType()). The field name in createFormData must match what the server expects — this is the form field name, not the filename.
Text fields alongside file uploads use a different form. Plain string values are wrapped as RequestBody: "description value".toRequestBody("text/plain".toMediaType()) and annotated with @Part("description") description: RequestBody. Alternatively, Retrofit accepts plain String parameters for simple text parts with the @Part annotation when no content type is needed. Media type accuracy matters: sending image/jpeg when the file is actually a PNG works in practice but violates the protocol. Detect the actual type from the file extension or file header bytes.
For large files — profile videos, document uploads, full-resolution photos — uploading through your own API server is inefficient and expensive. The server receives the bytes, validates them, and then must upload them to cloud storage anyway. The pre-signed URL pattern avoids this: the Android app requests a pre-signed S3 or GCS URL from your API, which generates a time-limited direct-upload URL. The app then uploads the file directly to cloud storage using a PUT request. The file bytes never touch your API server. This reduces API server bandwidth costs, eliminates the server as a bottleneck for large files, and is often faster for the user because cloud storage infrastructure is optimised for large binary uploads.
"For profile photos I use Multipart with Retrofit. For anything large (video, documents > 5MB) I use a pre-signed S3 URL from the backend — the client uploads directly to S3 with an expiring signed URL. This avoids routing megabytes through my backend server."
18. How do you convert network models (DTOs) to domain models? Why keep them separate?
DTOs (Data Transfer Objects) match the API's JSON exactly. Domain models represent your app's business concepts. Keeping them separate means a backend API change only affects the DTO layer — not your entire codebase.
// DTO — matches the API JSON exactly @Serializable data class UserDto( @SerialName("user_id") val userId: String, @SerialName("full_name") val fullName: String, @SerialName("email_addr") val emailAddr: String, @SerialName("birth_date") val birthDate: String, // "1990-05-15" — raw string @SerialName("account_type") val accountType: String // "PRO" / "FREE" — raw string ) // Domain model — clean, business-focused, no API details data class User( val id: String, val name: String, val email: String, val birthDate: LocalDate, // proper type, not string val tier: AccountTier // enum, not raw string ) enum class AccountTier { FREE, PRO, ENTERPRISE } // Mapper — extension function in the data layer fun UserDto.toDomain () = User( id = userId, name = fullName, email = emailAddr, birthDate = LocalDate.parse(birthDate), // String → LocalDate tier = when (accountType. uppercase ()) { "PRO" -> AccountTier.PRO "ENTERPRISE" -> AccountTier.ENTERPRISE else -> AccountTier.FREE } ) // Repository maps DTO → domain before returning class UserRepositoryImpl @Inject constructor(private val api: UserApi) : UserRepository { override suspend fun getUser(id: String): User = api.getUser (id).toDomain () // ViewModel never sees UserDto }
Data Transfer Objects (DTOs) are classes shaped to match the API response format. They carry serialization annotations (@SerialName("user_id")), use raw types that JSON can represent (String dates instead of LocalDate, integer status codes instead of enums), and live exclusively in the data layer. The DTO's purpose is faithful deserialization of the network response — nothing more. Domain models are the opposite: they use idiomatic Kotlin types (LocalDate, sealed classes, value classes), carry no serialization annotations, and represent the business concept that the rest of the app works with.
The mapper function is the only translation point between DTO and domain model. fun UserDto.toDomain(): User = User(id = userId, name = name, joinedAt = LocalDate.parse(createdAt), status = UserStatus.fromCode(statusCode)). The mapper converts raw API types to rich Kotlin types, applies any business rules about field interpretation, and produces a value the domain layer can use directly. The repository calls the API, receives a DTO, maps it, and returns the domain model. Nothing above the repository ever sees a DTO.
The practical value of this separation is change isolation. When the backend renames user_id to id, or changes the date format from Unix timestamp to ISO 8601, or adds a new status code — only the DTO and the mapper change. The User domain model, every use case, every ViewModel, and every composable remain untouched. This is not merely theoretical cleanliness: in a large app with dozens of screens using user data, a API field name change without this separation would require finding and updating every reference throughout the codebase. With it, the change is contained to two files.
"The maintenance argument: if the API renames 'user_id' to 'id', without this separation you'd grep the entire codebase and update 50 files. With DTOs, you change one @SerialName annotation. The mapper absorbs the change — everything above it is unaffected."
19. Scenario: How do you test a Repository that makes network calls? What tools and patterns do you use?
Testing network code in isolation requires either mocking the API interface or using MockWebServer to serve fake HTTP responses. Both approaches run fast, offline, and deterministically.
// Approach 1: Fake API (simplest — just implement the interface) class FakeUserApi : UserApi { var shouldThrow = false var userToReturn = UserDto("1", "Alice", "alice@example.com", "1990-01-01", "PRO") override suspend fun getUser(id: String): UserDto { if (shouldThrow) throw IOException("No internet") return userToReturn } } class UserRepositoryTest { private val fakeApi = FakeUserApi() private val repo = UserRepositoryImpl(fakeApi) @Test fun getUser_returnsUser() = runTest { val user = repo.getUser ("1") assertEquals("Alice", user.name) assertEquals(AccountTier.PRO, user.tier) // also tests mapper! } @Test fun getUser_onNetworkError_throwsNetworkException() = runTest { fakeApi.shouldThrow = trueassertThrows <NetworkException> { repo.getUser ("1") } } } // Approach 2: MockWebServer — real HTTP requests against a local server // testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") class UserRepositoryIntegrationTest { private val server = MockWebServer() @Before fun setUp() { server.start () } @After fun tearDown() { server.shutdown () } @Test fun getUser_parsesResponseCorrectly() = runTest { server.enqueue (MockResponse() .setResponseCode (200) .setBody ("""{"user_id":"1","full_name":"Alice","email_addr":"a@b.com","birth_date":"1990-01-01","account_type":"PRO"}""")) val retrofit =buildRetrofit (server.url ("/").toString ()) val repo = UserRepositoryImpl(retrofit.create (UserApi::class.java)) val user = repo.getUser ("1") assertEquals("Alice", user.name) } }
There are two complementary approaches to testing networking code, each with distinct strengths. The first is a fake API implementation: implement the Retrofit interface with a class that returns hardcoded responses. class FakeUserApi : UserApi { override suspend fun getUser(id: String) = UserDto(id, "Alice", "alice@example.com") }. Inject the fake into the repository and test the repository's mapping, business logic, and error handling without any network involvement. These tests run on the JVM in milliseconds, with no Android infrastructure needed.
MockWebServer (from OkHttp's test support library) creates a real HTTP server in the test process that serves pre-configured JSON responses. Tests use the full production stack: real OkHttp, real Retrofit, real converter factory, real deserializer. server.enqueue(MockResponse().setBody(userJson).setResponseCode(200)). The repository makes a network request to server.url("/"), OkHttp connects, the JSON is parsed by Kotlin Serialization, and the repository maps it to a domain model. MockWebServer tests verify that the JSON parsing configuration is correct — the right @SerialName annotations, correct null handling, proper error body parsing — which fake implementations cannot verify.
The two approaches are complementary, not competing. Use fakes for fast, isolation-focused unit tests: repository logic, error handling branches, edge cases. Use MockWebServer for integration tests that verify the full request-to-domain-model pipeline works correctly with realistic JSON. MockWebServer also supports verifying what the app sends — server.takeRequest() returns the received request, letting you assert that the correct path, headers, and body were sent. Both run completely offline, making them CI-safe and deterministic. Reserve real-network tests for end-to-end smoke tests in dedicated environments, not the main test suite.
"I use Fake API for unit tests — it's fast and tests the repository + mapper logic. I use MockWebServer for integration tests when I want to verify the JSON parsing specifically — feeding real JSON from a file and asserting the parsed model is correct. MockWebServer also lets me test error handling by returning 4xx/5xx responses."
20. What is an API gateway pattern? How does it affect your Android network code?
An API gateway sits between clients and microservices — it's a single entry point that handles auth, rate limiting, routing, and aggregation. From Android's perspective, you talk to one URL instead of dozens of microservice URLs.
// Without API gateway — Android talks to many services // https://users.api.com/users/123 // https://orders.api.com/orders // https://products.api.com/products // Each needs its own Retrofit instance, auth, error handling // With API gateway — single entry point // https://api.myapp.com/users/123 → gateway → users service // https://api.myapp.com/orders → gateway → orders service // https://api.myapp.com/products → gateway → products service // Android side — ONE Retrofit instance for everything val retrofit = Retrofit.Builder() .baseUrl ("https://api.myapp.com/") // single gateway URL .client (okHttpClient) // one auth interceptor for all services .build () // Benefits for Android: // ✅ One base URL, one auth token, one OkHttp client // ✅ Gateway handles rate limiting — app doesn't need to // ✅ Backend can change microservice topology without updating apps // ✅ CORS handled centrally // BFF (Backend for Frontend) — a gateway designed specifically for mobile // Instead of aggregating on Android: // screen needs: user + 3 recent orders + 5 products // Old way: 3 separate API calls from Android // BFF way: 1 call to /home-screen → gateway fetches all 3 and returns one response @Serializable data class HomeScreenData( val user: UserDto, val recentOrders: List<OrderDto>, val featuredProducts: List<ProductDto> ) // 1 network call instead of 3 — faster screen load
An API gateway is a single entry point that sits in front of all backend microservices. Instead of the Android app talking to a user service at users.api.example.com, a product service at products.api.example.com, and a cart service at cart.api.example.com — each with their own auth, versioning, and rate limiting — the app talks to one URL: api.example.com. The gateway routes requests to the correct service based on the path. From Android's perspective, there is one backend, one Retrofit instance, one auth interceptor, one SSL pinning configuration.
The Backend for Frontend (BFF) pattern extends this further. A BFF is a gateway layer purpose-built for mobile clients: instead of the app making three separate requests to assemble a home screen (featured products, user recommendations, active promotions), the BFF provides a single GET /home endpoint that makes those three backend calls server-side and returns one aggregated, mobile-optimised response. The aggregation happens on fast internal network between services, not over the cellular connection. The response shape is designed for the mobile screen, not for general-purpose API clients.
The abstraction benefit compounds over time. Backend teams can re-architect from a monolith to microservices, rename services, or change internal APIs — as long as the gateway contract stays stable, the Android app is unaffected. The gateway becomes the versioning boundary: it absorbs backend changes and presents a stable interface to mobile clients. The trade-off is that the gateway is a potential single point of failure, but production gateways are deployed across multiple instances with load balancing, making them more reliable than the individual backend services they route to.
"From Android's perspective, API gateway means I have one base URL, one OkHttp client, one auth setup. Without it I'd need separate Retrofit instances for each microservice with separate auth. The BFF pattern is even better — instead of 3 calls to populate a screen, I make 1 call to a purpose-built endpoint."
21. Scenario: Your app needs to paginate through a list of 10,000 products. How do you implement pagination with Retrofit and Paging 3?
Paging 3 is Jetpack's library for loading data in pages. You write a PagingSource that fetches one page at a time from Retrofit — Paging 3 handles loading states, error handling, and smooth scrolling automatically.
// API endpoint interface ProductApi { @GET("products") suspend fun getProducts( @Query("page") page: Int, @Query("per_page") perPage: Int = 20 ): PagedResponse<ProductDto> } @Serializable data class PagedResponse<T : @Serializable Any>( val data: List<T>, val totalPages: Int, val currentPage: Int ) // PagingSource — fetches one page at a time class ProductPagingSource( private val api: ProductApi ) : PagingSource<Int, Product>() { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Product> { val page = params.key ?: 1 // start from page 1 return try { val response = api.getProducts (page = page, perPage = params.loadSize) val products = response.data.map { it.toDomain () } LoadResult.Page ( data = products, prevKey = if (page == 1) null else page - 1, nextKey = if (page >= response.totalPages) null else page + 1 ) } catch (e: IOException) { LoadResult.Error (e) } catch (e: HttpException) { LoadResult.Error (e) } } override fun getRefreshKey(state: PagingState<Int, Product>) = state.anchorPosition } // Repository — creates the Pager fun getProducts(): Flow<PagingData<Product>> = Pager( config = PagingConfig(pageSize = 20, prefetchDistance = 5) ) { ProductPagingSource(api) }.flow // ViewModel val products = repo.getProducts ().cachedIn (viewModelScope) // Compose UI — LazyColumn handles paging automatically val products = vm.products.collectAsLazyPagingItems () LazyColumn { items(products) { item -> ProductCard(item) } }
Paging 3 is Android's library for loading data in pages from a network or database. The central abstraction is PagingSource<Key, Value>: a class you implement that knows how to fetch one page given a key. The load(params) method receives a LoadParams containing the current key (a page number, cursor, or offset) and a load size. It returns a LoadResult.Page(data, prevKey, nextKey) — the data for this page and the keys for adjacent pages. Returning nextKey = null signals that this is the last page; Paging 3 stops making further load requests.
The Pager class assembles the configuration — initial load size, page size, prefetchDistance — and exposes a Flow<PagingData<T>>. The ViewModel applies .cachedIn(viewModelScope) to this flow: caching means the loaded pages are retained across collectors. When the user rotates the device, the new Composable collects the same flow and receives the already-loaded pages immediately — no reload, no flash of loading state. Without cachedIn, every new collector triggers a full reload from the first page.
prefetchDistance controls how far from the edge Paging 3 begins loading the next page. A value of 5 means when the user scrolls to within 5 items of the end of the current page, the next page starts loading. Tuned correctly, the next page finishes loading before the user reaches it — smooth, seemingly infinite scrolling. Error handling is built in: returning LoadResult.Error(exception) from load() transitions the paging state to an error state. The Compose LazyColumn adapter provides a retry lambda that calls pagingItems.retry(), re-triggering the failed load from the same position without reloading the entire list.
"Paging 3 handles the hard parts: tracking which page to load next, showing loading spinners at the bottom, handling errors with retry buttons, and caching so rotation doesn't re-fetch everything. My PagingSource only needs to know how to fetch one page — Paging 3 orchestrates the rest."
22. What are network timeouts? How do you configure and choose the right values?
Timeouts prevent your app from waiting forever for a server that isn't responding. Three different timeouts control different phases of the HTTP connection — choosing the right values balances user experience against reliability.
// Three distinct timeout types: val client = OkHttpClient.Builder() // CONNECT TIMEOUT — how long to wait to establish the TCP connection // "Is the server reachable at all?" // Short is fine — if you can't connect in 15s, you can't connect .connectTimeout (15, TimeUnit.SECONDS) // READ TIMEOUT — how long to wait for data after connecting // "The server is connected but is it sending data?" // Longer for slow servers or large responses .readTimeout (30, TimeUnit.SECONDS) // WRITE TIMEOUT — how long to wait while sending data // "How long to wait while uploading our request body?" // Longer for file uploads, shorter for simple JSON POSTs .writeTimeout(30, TimeUnit.SECONDS) // CALL TIMEOUT — overall maximum for the entire request (OkHttp 4+) // Hard cutoff regardless of any other timeout . callTimeout (60, TimeUnit.SECONDS) .build () // Override per-request (for slow endpoints like report generation) interface ReportApi { @POST("reports/generate") @Headers("Timeout: 120") // custom header your interceptor reads suspend fun generateReport(@Body params: ReportParams): Report } // Practical guidelines for mobile: // Regular API calls: connect 15s, read 30s, write 30s // File uploads: write 120s (large payloads take time) // Long polling: read 60s+ (waiting for server-sent events) // Health checks: connect 5s, read 5s (fail fast) // On timeout → SocketTimeoutException (subclass of IOException) // Your safeApiCall wrapper catches IOException → show "Request timed out"
OkHttp exposes four independent timeout settings, each covering a different phase of the HTTP lifecycle. The connect timeout is the maximum time allowed to establish a TCP connection to the server — DNS resolution, TCP handshake, TLS handshake. On mobile networks, 10–15 seconds is typical. If the server is unreachable or the network is congested, the connect timeout fires and throws a SocketTimeoutException. The read timeout is the maximum time to wait for bytes after the connection is established — it applies per block of data received, not the total response. For normal API responses, 30 seconds is generous. Long-polling or streaming endpoints need a larger or explicitly disabled read timeout.
The write timeout limits how long OkHttp waits to write the request body to the connection. For typical JSON request bodies of a few kilobytes, the default 10 seconds is ample. For file uploads on slow connections, increase this proportionally to the expected upload size. The call timeout is the overall wall-clock limit for the entire request lifecycle — from the moment the request is enqueued to the moment the response body is fully consumed. It is the safety net that prevents any single request from hanging forever regardless of which phase stalls. Set it to something like 60 seconds as a hard cap.
All timeout violations throw SocketTimeoutException, which is a subclass of IOException. Your safeApiCall wrapper catches all IOException types, so timeout handling is automatic. The user-visible consequence of a timeout should be a retry prompt or an offline state, not a raw exception message. Different endpoints may need different timeout profiles: a quick status check endpoint should have a short timeout (5 seconds), while a heavy report generation endpoint might legitimately take 90 seconds. Configure timeouts per OkHttpClient or override them per request using newBuilder() on the call-level client.
"Mobile users on 3G can have high latency — connect timeout of 15s is better than 5s. But read timeout of 30s means if the server hangs after connecting, the user waits 30 seconds before seeing an error. For user experience: show a progress indicator and let them cancel if needed rather than silently waiting."
23. How do you use Retrofit with Kotlin coroutines? What are the advantages over callbacks?
Retrofit has built-in coroutine support — just add suspend to your interface functions. No callbacks, no threading boilerplate, no callback hell. Your network code reads like sequential code but runs asynchronously.
// OLD WAY: Callbacks — nested, hard to read, error-prone fun loadUserOldWay(id: String) { api.getUser(id). enqueue (object : Callback<User> { override fun onResponse(call: Call<User>, response: Response<User>) { if (response.isSuccessful()) { val user = response.body() // now get the user's orders... api. getOrders (user!!.id).enqueue (object : Callback<List<Order>> { // callback hell 😱 }) } } override fun onFailure(call: Call<User>, t: Throwable) { /* ... */ } }) } // ✅ NEW WAY: Coroutines — reads like sequential code interface UserApi { @GET("users/{id}") suspend fun getUser(@Path("id") id: String): User // just add suspend @GET("users/{id}/orders") suspend fun getOrders(@Path("id") id: String): List<Order> } suspend fun loadUserAndOrders(userId: String): UserWithOrders { val user = api.getUser (userId) // sequential — natural reading order val orders = api.getOrders (userId) // runs after user is fetched return UserWithOrders(user, orders) } // Parallel calls — async/await suspend fun loadDashboard(userId: String): Dashboard = coroutineScope { val userDeferred = async { api.getUser (userId) } val ordersDeferred = async { api.getOrders (userId) } val newsDeferred = async { api.getNews () } // All 3 run in parallel — total time = slowest, not sum Dashboard(userDeferred.await (), ordersDeferred.await (), newsDeferred.await ()) } // Retrofit runs on IO thread by default — no withContext(Dispatchers.IO) needed for Retrofit calls // Room queries do need withContext(Dispatchers.IO) if not using @Query's built-in async
Retrofit's coroutine support is transparent: add suspend to the interface method and Retrofit generates a coroutine-compatible implementation. When called from a coroutine, Retrofit dispatches the network call on OkHttp's internal thread pool without blocking the calling coroutine's thread, then resumes the coroutine with the result when the response arrives. You do not need to wrap Retrofit calls in withContext(Dispatchers.IO) — Retrofit handles its own dispatching. The calling code reads synchronously from top to bottom, which is a dramatic improvement over callback-based networking.
Error handling is also sequential. Instead of separate success and failure callbacks — onResponse and onFailure — you use ordinary try-catch: try { val user = api.getUser(id); updateUi(user) } catch (e: IOException) { showNetworkError() } catch (e: HttpException) { showServerError(e.code()) }. The control flow is linear and the error branches are co-located with the success branch. Callback-based approaches require splitting success and error handling across two separate lambdas, making it easy to miss error cases or duplicate logic.
Parallel requests use async within a coroutineScope: coroutineScope { val user = async { api.getUser(id) }; val orders = async { api.getOrders(id) }; val reviews = async { api.getReviews(id) }; render(user.await(), orders.await(), reviews.await()) }. All three requests are in flight simultaneously. The total time equals the slowest individual request rather than the sum of all three. coroutineScope provides structured concurrency: if any of the three requests throws, the others are automatically cancelled and the exception propagates to the caller. Use supervisorScope instead if you want independent failure handling — one request's failure does not cancel the others, allowing partial success to be displayed.
"Coroutines eliminated callback hell in networking. Two requests that depend on each other: sequential suspend calls, reads like synchronous code. Two independent requests: async { } both, await() both — they run in parallel. Try-catch handles errors. This is the biggest quality-of-life improvement in modern Android networking."
24. What is WebSocket and how do you implement real-time communication in Android?
HTTP is request-response — the client asks, the server answers. WebSocket is a persistent bidirectional channel — server can push data anytime. Perfect for chat, live scores, stock prices, and notifications.
// WebSocket with OkHttp — direct support, no extra library needed class ChatWebSocketImpl @Inject constructor( private val client: OkHttpClient ) : ChatWebSocket { private val _events = MutableSharedFlow<ChatEvent>(extraBufferCapacity = 64) override val events: SharedFlow<ChatEvent> = _events private var webSocket: WebSocket? = null override fun connect(url: String, token: String) { val request = Request.Builder() .url (url) .header ("Authorization", "Bearer $token") .build () webSocket = client.newWebSocket (request, object : WebSocketListener() { override fun onOpen(ws: WebSocket, response: Response) { _events.tryEmit (ChatEvent.Connected) } override fun onMessage(ws: WebSocket, text: String) { val msg = Json.decodeFromString <ChatMessage>(text) _events.tryEmit (ChatEvent.Message (msg)) } override fun onClosed(ws: WebSocket, code: Int, reason: String) { _events.tryEmit (ChatEvent.Disconnected) } override fun onFailure(ws: WebSocket, t: Throwable, r: Response?) { _events.tryEmit (ChatEvent.Error (t)) } }) } override fun send(message: String) { webSocket?.send (message) } override fun disconnect() { webSocket?.close (1000, "Goodbye"); webSocket = null } } // Alternatives to raw WebSocket: // Firebase Realtime Database — managed WebSocket, offline support, free tier // Socket.IO — higher-level, auto-reconnect, rooms, namespaces // Ktor client — Kotlin-native WebSocket with coroutine Flow integration
WebSockets provide a persistent, bidirectional TCP connection between client and server. Unlike REST, where the client must poll for updates, a WebSocket connection allows the server to push messages to the client at any time. The initial HTTP handshake upgrades the connection to the WebSocket protocol, after which both sides can send frames independently. This is ideal for chat applications, live scoreboards, collaborative editing, real-time market data, and any scenario where low-latency server-initiated updates are required.
OkHttp supports WebSockets natively: okHttpClient.newWebSocket(request, listener) opens the connection and calls the WebSocketListener callbacks on events. The callbacks are synchronous and run on OkHttp's thread pool. To expose the WebSocket stream as a Kotlin Flow — which the ViewModel can collect — use callbackFlow: inside the flow builder, open the WebSocket with a listener that calls trySend(message) on each onMessage callback, and close the flow in awaitClose when the flow collector cancels. The ViewModel collects a SharedFlow version of this, observing real-time events with lifecycle awareness.
WebSocket connections drop frequently on mobile — the network switches between Wi-Fi and cellular, the device sleeps, or the server restarts. The onFailure callback fires when the connection drops. Implement reconnection with exponential backoff and jitter: after the first failure wait 1 second, then 2, 4, 8 — up to a maximum. Track whether the app is in the foreground before reconnecting; there is no point maintaining a WebSocket while the app is backgrounded. Alternatives worth knowing: Firebase Realtime Database manages the WebSocket connection internally and handles reconnection, offline caching, and conflict resolution. Ktor's WebSocket client is Kotlin-native and KMP-compatible. Socket.IO provides a higher-level abstraction with rooms and namespaces but requires a Socket.IO-compatible server.
"The hardest part of WebSocket isn't connecting — it's reconnection. The network drops silently, onFailure fires, and you need to reconnect with backoff without losing messages. I emit events to a SharedFlow so the ViewModel observes them reactively, and the ViewModel triggers reconnect on ChatEvent.Error."
25. Scenario: Design the complete networking layer for a production Android app — from OkHttp setup to the ViewModel. What decisions do you make and why?
A production networking layer requires intentional decisions at every level — security, performance, error handling, testability, and maintainability all have to work together. Here's what a senior engineer sets up.
// LAYER 1: OkHttp — the engine @Provides @Singleton fun provideOkHttp( authInterceptor: AuthInterceptor, // adds Bearer token tokenRefreshInterceptor: TokenRefreshInterceptor // handles 401 ): OkHttpClient = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) . readTimeout (30, TimeUnit.SECONDS) .addInterceptor (authInterceptor) .addInterceptor (tokenRefreshInterceptor) .apply { if (BuildConfig.DEBUG)addInterceptor (HttpLoggingInterceptor().apply { level = Level.BODY }) } .certificatePinner (buildCertPinner()) // SSL pinning in production .build () // LAYER 2: Retrofit — type-safe interface @Provides @Singleton fun provideRetrofit(client: OkHttpClient): Retrofit = Retrofit.Builder() .baseUrl (BuildConfig.API_BASE_URL) .client (client) .addConverterFactory (Json { ignoreUnknownKeys = true } .asConverterFactory("application/json". toMediaType ())) .build () // LAYER 3: Repository — error handling, DTO mapping, caching class ProductRepositoryImpl @Inject constructor( private val api: ProductApi, private val dao: ProductDao ) : ProductRepository { override fun getProducts(): Flow<List<Product>> = dao.observeAll ().map { it.map { e -> e.toDomain () } } override suspend fun refresh(): Result<Unit> =runCatching { val fresh =safeApiCall { api.getProducts () } dao.insertAll((fresh as ApiResult. Success ).data.map { it.toEntity () }) } } // LAYER 4: ViewModel — state management @HiltViewModel class ProductViewModel @Inject constructor( private val repo: ProductRepository ) : ViewModel() { val products = repo.getProducts ().stateIn (viewModelScope, SharingStarted.WhileSubscribed (5000),emptyList ()) fun refresh() = viewModelScope.launch { repo.refresh () } }
A production-ready networking stack has layers with defined responsibilities. At the foundation: one @Singleton OkHttpClient configured with an auth interceptor (adds Authorization: Bearer header), a token refresh interceptor (handles 401 with mutex-protected refresh), a logging interceptor (debug builds only, with token redaction), and a CertificatePinner for critical endpoints. Timeouts set to reasonable mobile values. The client is built in a Hilt module and shared across all Retrofit instances.
The Retrofit instance is configured with the production base URL from a typed AppConfig binding (not raw BuildConfig), a Kotlin Serialization converter with ignoreUnknownKeys = true and isLenient = false, and a Result<T> call adapter factory for uniform error wrapping. Repositories inject the API interface, call methods inside a safeApiCall wrapper, and map DTOs to domain models before returning. Repositories also implement offline-first behaviour: return cached Room data immediately, then refresh from network in the background and emit the update. The ViewModel collects a StateFlow using stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Loading) — the upstream flow is cancelled 5 seconds after the last collector disappears, keeping the state alive through brief configuration changes without wasting resources when the app is backgrounded.
Testability at every layer: the Retrofit interface is tested with a FakeApi implementation for repository unit tests — fast, in-memory, no network. Integration tests use MockWebServer to test the full Retrofit + OkHttp + deserialization + mapping pipeline with real HTTP. The ViewModel is tested by injecting a fake repository that exposes a MutableStateFlow, advancing its state in tests and asserting the ViewModel's derived UI state. No layer in this stack requires mocking a concrete class — every dependency is injected through an interface, making each layer independently testable in milliseconds.
"The decisions I highlight: ignoreUnknownKeys so old app versions survive API updates, SSL pinning with two hashes for rotation safety, Mutex in token refresh to prevent simultaneous refresh calls, and offline-first with Room as the source of truth so users always see something. Each decision has a production war story behind it."
26. What is HTTP/2? How does it improve performance over HTTP/1.1?
HTTP/2 multiplexes multiple requests over a single TCP connection, eliminating the head-of-line blocking that limits HTTP/1.1. It also compresses headers (HPACK) and supports server push. OkHttp uses HTTP/2 automatically when the server supports it -- no configuration needed.
// OkHttp uses HTTP/2 automatically -- verify with logging interceptor val logging = HttpLoggingInterceptor().apply { level = Level.BASIC } // Log output shows: "Protocol: h2" when HTTP/2 is negotiated val client = OkHttpClient.Builder() .addInterceptor(logging) .build() // HTTP/2 connection pool -- one connection handles all parallel requests // HTTP/1.1 needed multiple connections (default max 6 per host) // HTTP/2 streams: up to ~128 parallel requests on one TCP connection
HTTP/2's most impactful feature for Android is multiplexing. HTTP/1.1 connections are sequential: you send a request, wait for the response, then send the next. Pipelining was an attempted fix but was largely abandoned due to implementation problems. HTTP/2 introduces streams: multiple requests share a single TCP connection and travel simultaneously, each in its own numbered stream. Sending three API requests no longer means waiting for the first to complete before sending the second — all three travel in parallel over the same connection, and responses arrive as they are ready.
Head-of-line blocking is HTTP/1.1's specific problem: a slow response blocks the queue for all subsequent requests on that connection. Browsers work around this by opening 6–8 parallel TCP connections per domain, which wastes sockets and defeats connection reuse. HTTP/2's independent streams mean a slow response in stream 3 does not delay stream 4 or 5 — they are fully independent. Header compression (HPACK) provides additional bandwidth savings: HTTP headers like Authorization, Accept, User-Agent, and Content-Type are the same on almost every request. HPACK compresses these via a shared header table — on mobile where headers can exceed the body size for small API calls, this reduction is meaningful.
OkHttp negotiates HTTP/2 automatically through ALPN (Application-Layer Protocol Negotiation) during the TLS handshake. If the server supports HTTP/2, OkHttp uses it — no configuration required. You can verify HTTP/2 is being used by logging the Protocol from OkHttpClient.EventListener. Server push — where the server proactively sends resources the client has not yet requested — is theoretically useful but in practice poorly supported by most mobile backends and proxy layers. Focus on multiplexing as the primary benefit. HTTP/2 is available on all Android versions OkHttp supports and all modern servers, so there is no reason not to use it.
"HTTP/2 multiplexing is why making many small parallel API calls is now feasible on mobile. In HTTP/1.1, browsers limited to 6 connections per domain. With HTTP/2, all requests share one connection with no limit. OkHttp handles this transparently — my code doesn't change at all."
27. What is connection pooling in OkHttp? Why does it matter for performance?
Opening a TCP connection is expensive — DNS lookup, TCP handshake, TLS handshake can take 100-300ms. Connection pooling keeps connections open and reuses them for future requests. OkHttp does this automatically.
// WITHOUT pooling — every request pays the full cost: // DNS lookup: ~20ms // TCP handshake: ~50ms // TLS handshake: ~100ms // Total overhead: ~170ms before even sending your request! // 10 requests = 1700ms wasted on connection setup alone // WITH pooling — connection reused: // First request: pays the 170ms overhead // All subsequent requests: 0ms overhead (connection already open) // 10 requests = 170ms total overhead // OkHttp default pool: 5 connections, 5 min keep-alive // Customise if needed (rarely required): val connectionPool = ConnectionPool( maxIdleConnections = 10, // max idle connections to keep keepAliveDuration = 5, // how long to keep them timeUnit = TimeUnit.MINUTES ) val client = OkHttpClient.Builder() .connectionPool (connectionPool) .build () // Critical: share ONE OkHttpClient across your entire app // ❌ BAD — new client = new pool, no reuse fun makeRequest() { val client = OkHttpClient() // creates new pool every call! } // ✅ GOOD — @Singleton in Hilt @Provides @Singleton fun provideOkHttp(): OkHttpClient = OkHttpClient.Builder().build ()
Every new TCP connection to a server requires a DNS lookup, a TCP three-way handshake, and a TLS handshake — typically 100–300ms on a mobile network before any application data is exchanged. For an app that makes frequent API calls, paying this overhead on every request is prohibitive. Connection pooling solves this: after a request completes, OkHttp keeps the connection open in a pool. The next request to the same host reuses the existing connection immediately, skipping the handshake overhead entirely.
OkHttp's default pool maintains up to 5 idle connections per host with a 5-minute keep-alive. After 5 minutes of inactivity, idle connections are closed to reclaim resources. For most Android apps — a handful of API endpoints on one or two hosts — the default configuration is appropriate. Apps with very high request rates or many concurrent users might benefit from tuning the pool size, but premature optimisation here is rarely warranted. The keep-alive duration is also negotiated with the server via the Keep-Alive header; the effective lifetime is the minimum of the client and server values.
The most important operational rule is: one OkHttpClient per app. The client owns the connection pool and the thread pool. Creating a new client per API call or per Retrofit instance means each client maintains its own pool — they cannot share connections, the pool benefit is lost, and each client allocates its own threads. A @Singleton OkHttp client injected via Hilt ensures all network calls across the entire app share one pool. With HTTP/2, this is even more beneficial: all requests to the same host travel over a single multiplexed connection, and connection pooling ensures that connection is reused across the session rather than re-established.
"The most common networking performance mistake I see in code reviews: creating a new OkHttpClient per request or per Repository. This defeats connection pooling entirely. The client must be @Singleton — that one instance maintains the pool that all requests share."
28. Scenario: Your app sends user analytics events. Should you batch them or send individually? How do you implement batching?
Always batch analytics — sending one event per HTTP request wastes battery (each wakes the radio), adds latency, and can overload your server. Collect events locally, flush in batches.
// ❌ BAD: One HTTP request per event fun trackEvent(event: String) { api.sendEvent (event) // HTTP call per event = battery drain + server load } // ✅ GOOD: Collect locally, flush in batches class AnalyticsBatcher @Inject constructor( private val api: AnalyticsApi, @ApplicationScope private val scope: CoroutineScope ) { private val queue = Channel<AnalyticsEvent>(Channel.UNLIMITED) init { scope.launch { queue.receiveAsFlow () .buffer (100) // collect up to 100 events .chunked (20) // batch into groups of 20 .collect { batch -> try { api.sendBatch (batch) } catch (e: Exception) { /* persist to DB for retry */ } } } } fun track(event: AnalyticsEvent) { queue.trySend (event) } } // Better: time-based flushing with WorkManager @HiltWorker class AnalyticsFlushWorker @AssistedInject constructor(...) : CoroutineWorker(...) { override suspend fun doWork(): Result { val pending = dao.getPendingEvents () if (pending.isEmpty ()) return Result.success () api.sendBatch (pending) // send all at once dao.markSent (pending) // mark as delivered return Result.success () } } // Schedule: every 30 minutes OR when batch reaches 50 events // Constraints: requiresNetwork(true) — only sends when connected
Sending one HTTP request per analytics event is one of the most battery-expensive patterns in Android development. The cellular radio operates in a power state machine: it activates for a request and then stays in a high-power "tail" state for 10–20 seconds waiting for more data before powering down. Each separate request triggers its own radio activation and tail window. An app that sends 50 events per session as 50 separate requests keeps the radio active for 8–16 minutes. Batching all 50 events into one request activates the radio once and the tail window fires once — an order-of-magnitude reduction in battery impact.
The implementation pattern is an in-memory queue combined with a flush trigger. A Channel<AnalyticsEvent> collects events as they occur. A background coroutine drains the channel into a list, flushing when the list reaches a batch size threshold (20–50 events is typical) or when a timer fires (every 30 seconds). The flush sends one request containing all queued events. This balances latency — events are not delayed indefinitely — against efficiency — we do not make a request for every event. The timer-based flush is essential for low-traffic sessions where the batch size threshold is never reached.
Durability requires persisting events that have not yet been flushed. If the app is killed — by the system, by the user, or by a crash — in-memory queued events are lost. Persisting to Room before acknowledgement and deleting from Room after successful flush ensures events survive process death. A WorkManager periodic worker with a requiresNetwork() constraint acts as the flush mechanism: it runs when the device has connectivity, reads unbatched events from Room, sends them, and marks them as flushed. This architecture handles poor connectivity gracefully — events accumulate locally and flush in bulk when a stable connection is available, reliably delivering analytics without impacting user-visible API performance.
"Every network request on mobile wakes the cellular radio — and it stays awake for ~20 seconds (the 'tail time'). Sending 100 events individually = 100 radio wakeups. Sending them in 5 batches of 20 = 5 wakeups. This is why Firebase Analytics batches events and flushes every 30 minutes or on app background."
29. What is HTTPS and why is it mandatory in modern Android apps?
HTTPS is HTTP over TLS (Transport Layer Security). It encrypts all data in transit, authenticates the server's identity via a certificate, and verifies data integrity. Android has required HTTPS for all network traffic by default since Android 9 (API 28) -- cleartext HTTP requires explicit opt-in via Network Security Config.
// cleartext blocked by default on API 28+ -- opt-in only for debug // res/xml/network_security_config.xml // <network-security-config> // <debug-overrides><trust-anchors> // <certificates src="user"/> <!-- allow user-installed certs in debug --> // </trust-anchors></debug-overrides> // </network-security-config> // TLS handshake steps (simplified) // 1. Client → Server: ClientHello (supported cipher suites) // 2. Server → Client: ServerHello + Certificate // 3. Client: verify certificate chain → extract public key // 4. Client + Server: derive symmetric session key // 5. All subsequent traffic encrypted with session key val client = OkHttpClient.Builder() .sslSocketFactory(sslContext.socketFactory, trustManager) .build()
TLS (Transport Layer Security) provides three security properties for network traffic. Encryption ensures data in transit is unreadable to eavesdroppers — even on a compromised Wi-Fi network, the content of API requests and responses is ciphertext. Authentication ensures the server is who it claims to be: the TLS handshake involves validating the server's certificate against a trusted certificate authority, preventing an attacker from impersonating your backend. Integrity guarantees that data has not been tampered with in transit — any modification to the encrypted payload breaks the authentication code and is rejected.
Android has progressively tightened cleartext (plain HTTP) restrictions. From Android 9 (API 28) onward, cleartext HTTP is blocked by default for all connections. Apps targeting API 28+ that attempt a plain HTTP request throw a CLEARTEXT communication to host not permitted exception. The correct fix is to use HTTPS everywhere. For development tools that require HTTP (a local dev server, a mock server on the same machine), use Network Security Config to explicitly permit cleartext for specific domains in debug builds only — never in release builds.
Network Security Config (res/xml/network_security_config.xml, referenced from the manifest) is the structured way to express your app's network security policy. You can define trust anchors (which CAs to trust, including user-installed CAs in debug builds only), explicitly permit cleartext for specific domains, and declare certificate pins with backup hashes and expiry dates. TLS 1.3 is supported from Android 10 and offers a faster handshake (1-RTT vs 2-RTT in TLS 1.2), stronger mandatory cipher suites, and forward secrecy by default (so past session keys cannot be derived if the server's long-term key is ever compromised). OkHttp negotiates TLS 1.3 automatically with supporting servers.
"Every Android app should use HTTPS exclusively. The common mistake during development: allowing cleartext in the main manifest instead of only in the debug-flavored network_security_config. Put cleartext allowances only in src/debug/res/xml/ — they never reach production builds."
30. Scenario: Your app downloads large images and PDFs. How do you implement download progress and cancellation?
Large file downloads need real-time progress and the ability to cancel mid-download. OkHttp's ResponseBody combined with a Flow is the cleanest approach in modern Android.
// Download with progress using Flow fun downloadFile(url: String, dest: File): Flow<DownloadState> =flow { val request = Request.Builder().url (url).build () val response = client.newCall (request).execute () if (!response.isSuccessful ()) {emit (DownloadState.Error ("HTTP ${response.code}")); return@flow } val body = response.body ?: run {emit (DownloadState.Error ("Empty body")); return@flow } val totalBytes = body.contentLength() // -1 if unknown var bytesRead = 0L body.byteStream ().use { inputStream -> dest.outputStream ().use { outputStream -> val buffer = ByteArray(8192) var bytes: Int while (inputStream.read (buffer).also { bytes = it } != -1) {ensureActive () // cancels the flow if coroutine is cancelled outputStream.write (buffer, 0, bytes) bytesRead += bytesval progress =if (totalBytes > 0) (bytesRead * 100 / totalBytes).toInt ()else -1emit (DownloadState.Progress (progress, bytesRead)) } } }emit (DownloadState.Success (dest)) }.flowOn (Dispatchers.IO) sealed class DownloadState { data class Progress(val percent: Int, val bytesRead: Long): DownloadState() data class Success(val file: File): DownloadState() data class Error(val msg: String): DownloadState() } // In ViewModel — cancel by cancelling the job private var downloadJob: Job? = null fun startDownload(url: String) { downloadJob = viewModelScope.launch { repo.downloadFile (url, destFile).collect { state -> _uiState.update { it.copy (downloadState = state) } } } } fun cancelDownload() { downloadJob?.cancel () }
Downloading a large file requires streaming the response body rather than reading it all into memory. A Flow<DownloadState> bridges the streaming IO work with the ViewModel's state: the flow emits DownloadState.Progress(percent) events as bytes arrive, and finally DownloadState.Complete(file). The flow is created in the repository and collected in the ViewModel, which maps progress events to UI state for a progress bar. flowOn(Dispatchers.IO) ensures all the IO operations — reading from the network, writing to disk — execute on the IO dispatcher while the flow's downstream (in the ViewModel) runs on the main thread.
Reading in chunks is essential for memory efficiency. source.read(buffer, 8192) reads up to 8KB at a time, writes it to the output file, updates the bytes-read counter, and emits a progress event. Reading the entire body at once with body.bytes() loads the entire file into heap memory — a 100MB download would consume 100MB of heap, likely triggering an OutOfMemoryError. The chunk-based loop keeps memory usage constant regardless of file size: only one 8KB buffer is in memory at any time.
currentCoroutineContext().ensureActive() at the top of the read loop is the cancellation hook. When the user cancels the download (or navigates away, cancelling the ViewModel scope), the collecting coroutine is cancelled. Without ensureActive(), the IO loop would continue reading and writing until the download completes, wasting bandwidth and battery. With it, the cancellation is checked on every iteration — as soon as the coroutine is cancelled, ensureActive() throws a CancellationException that unwinds the stack and closes the file. Handle the case where contentLength() returns -1 — some servers do not send Content-Length — by treating the progress as indeterminate and showing a spinner rather than a percentage.
"ensureActive() is the key to cancellable downloads. Without it, the while loop keeps reading even after the user presses cancel. With ensureActive() on every iteration, the download stops within 8KB of the cancel call. For production apps with large files, also consider using DownloadManager or WorkManager for background downloads that survive app closure."
31. What is the difference between query parameters, path parameters, and request headers? When do you use each?
These three mechanisms put data in different parts of an HTTP request. The right choice affects API design, security, and cacheability.
// PATH PARAMETER — part of the resource URL itself // Use when: identifying a specific resource // GET /users/123/orders/456 @GET("users/{userId}/orders/{orderId}") suspend fun getOrder( @Path("userId") userId: String, @Path("orderId") orderId: String ): Order // ✅ Cacheable by CDNs and proxies // ✅ RESTful — resource identity in URL // ❌ Don't put sensitive data here (appears in server logs) // QUERY PARAMETER — filters and options after "?" // Use when: filtering, sorting, pagination // GET /products?category=shoes&sort=price&page=2 @GET("products") suspend fun getProducts( @Query("category") category: String? = null, // null = omitted @Query("sort") sort: String = "date", @Query("page") page: Int = 1 ): List<Product> // ✅ Optional — null values are omitted from URL // ✅ Cacheable with the right Cache-Control // ❌ Don't put auth tokens here — they end up in server logs and browser history // REQUEST HEADER — metadata about the request // Use when: auth tokens, content type, API version, client info @GET("products") suspend fun getProductsV2( @Header("X-Api-Version") version: String = "2.0" ): List<Product> // ✅ Not stored in server logs (usually) // ✅ Best for auth tokens — Authorization: Bearer ... // ❌ Not cacheable by intermediaries (they ignore most custom headers) // Rule of thumb: // "What are you accessing?" → @Path // "How do you want it?" → @Query // "Who are you?" → @Header
The choice between @Path, @Query, and @Header is partly a RESTful design question and partly a security question. @Path embeds a value in the URL path — /users/123 identifies the specific user resource. @Query appends key-value parameters to the URL — /products?sort=price&category=electronics filters the product collection. Both appear in the URL, which means they appear in server access logs, browser history, proxy logs, and referrer headers. @Header values travel in HTTP headers, which are not typically logged in plain text by web servers.
The security implication is clear: never put authentication tokens, API keys, session identifiers, or any other secret values in @Path or @Query. A URL like /api/data?api_key=abc123secret will appear in every server access log, every proxy log, every CDN log, and every analytics tool that records request URLs. Any system that stores logs now stores your user's secrets. Bearer tokens belong in the Authorization header, added by the auth interceptor — headers are not exposed in standard server access logs and are not forwarded in referrer headers when the user follows a link.
Null @Query handling is a practical convenience: Retrofit automatically omits query parameters whose value is null. This enables optional filter parameters without conditional URL building: @GET("products") suspend fun getProducts(@Query("category") category: String?, @Query("maxPrice") maxPrice: Int?): List<ProductDto>. Passing null for either parameter simply omits it from the URL — no need to build different URLs for different combinations of filters. The same endpoint serves filtered and unfiltered queries without branching in the repository.
"The most common security mistake: putting an API key or auth token as a query parameter — ?api_key=secret. This gets logged in every server access log, CDN log, and browser history forever. Auth tokens always go in headers: Authorization: Bearer token."
32. What is Server-Sent Events (SSE)? How does it compare to WebSocket and polling?
SSE is a one-way push from server to client over HTTP. Simpler than WebSocket (no bidirectional protocol), more efficient than polling (no repeated requests). Perfect for live feeds, notifications, and status updates.
// Three approaches for server-to-client real-time data: // POLLING — client asks repeatedly (worst) // GET /updates every 5 seconds // ❌ 90% of requests return "nothing new" // ❌ Battery drain, server load, latency (up to 5 seconds) // SSE — server pushes over persistent HTTP connection (sweet spot) // Client opens ONE connection; server streams text/event-stream // Format: "data: {json}\n\n" // ✅ Works over HTTP (proxies, CDNs work normally) // ✅ Auto-reconnects built into browser/OkHttp // ✅ One-way push — simpler than WebSocket // ❌ Client can't send messages (use REST for that) // SSE with OkHttp — using EventSource // implementation("com.squareup.okhttp3:okhttp-sse:4.12.0") val request = Request.Builder() .url ("https://api.example.com/events") .header("Authorization", "Bearer $token") . build() val eventSource = EventSources. createFactory (client) .newEventSource (request, object : EventSourceListener() { override fun onEvent(es: EventSource, id: String?, type: String?, data: String) { val event = Json.decodeFromString <LiveUpdate>(data) _events.tryEmit (event) } override fun onFailure(es: EventSource, t: Throwable?, r: Response?) { // OkHttp SSE auto-reconnects — handle persistent failures only } }) // WEBSOCKET — bidirectional (when you need both directions) // Use for: chat, collaborative editing, live gaming // When to use what: // Polling: simple, data changes rarely, small team, few users // SSE: live feeds, order tracking, sports scores, notifications // WebSocket: chat, collaborative apps, real-time multiplayer
Server-Sent Events (SSE) is an HTTP-based protocol for server-to-client push. The client makes a normal HTTP GET request with Accept: text/event-stream, and instead of the server closing the connection after the response, it keeps the connection open and sends newline-delimited text events as they occur: data: {"orderId":"123","status":"shipped"}
. Each event is a text frame. The client reads the stream and parses events as they arrive. The protocol is simpler than WebSocket — it is just HTTP, built on top of a persistent connection.
SSE is one-directional: server to client only. The client cannot send messages on the SSE connection — it can only listen. For client-to-server communication, the app uses ordinary REST requests alongside the SSE connection. This is actually a feature for many use cases: tracking order status, receiving push notifications, live score updates, stock price tickers. None of these require the client to send data on the same persistent connection. The architectural simplicity of one-way push is appropriate when you do not need the full bidirectionality of WebSocket.
SSE's most important practical advantage over WebSocket is HTTP compatibility. Corporate firewalls, CDNs, and load balancers are often configured to terminate WebSocket upgrades or time out persistent WebSocket connections. SSE is plain HTTP — it uses the same ports (80/443) and traverses every HTTP-compatible infrastructure without special configuration. The protocol also specifies automatic reconnection: the client reconnects after a disconnect and sends the Last-Event-ID header so the server can resume from where it left off. OkHttp does not have a built-in SSE client, but you can implement it using a raw OkHttp streaming call wrapped in a callbackFlow.
"SSE is underused on Android. For order tracking (server pushes status updates), SSE is simpler than WebSocket — you don't need bidirectional communication. It runs over plain HTTP so it works in corporate networks that block WebSocket. The OkHttp SSE library makes it a 10-line implementation."
33. How do you handle API versioning in Android? What happens when the backend changes the API?
API versioning ensures older app versions keep working when the backend evolves. Android apps can't be force-updated — users on old versions will keep making requests forever, so backward compatibility is critical.
// Strategy 1: Version in URL (most common) val retrofit = Retrofit.Builder() .baseUrl ("https://api.example.com/v2/") // version in base URL .build () // Strategy 2: Version in header (cleaner URLs) @GET("products") suspend fun getProducts( @Header("X-Api-Version") version: String = "2" ): List<Product> // Better: add version header globally via interceptor class ApiVersionInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response = chain.proceed (chain.request().newBuilder () .header("X-Api-Version", "2") . header ("X-App-Version", BuildConfig.VERSION_NAME) // useful for server analytics .build ()) } // Protect against breaking changes with ignoreUnknownKeys: val json = Json { ignoreUnknownKeys = true } // If server adds new fields → old app versions don't crash // Minimum version enforcement: // Server returns 426 Upgrade Required when app is too old class MinVersionInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val response = chain.proceed (chain.request()) if (response.code == 426) { // Emit a global event to show "Please update the app" dialog } return response } }
API versioning for mobile has a constraint that web development does not: you cannot force users to update their app. A user who installed your app two years ago and never updated is still making API requests with the version they have. Your backend must continue to support old API versions for as long as statistically significant numbers of users have those app versions installed. Google Play Console's Android Vitals shows the version distribution of your active users — this is the data that tells you when it is safe to deprecate an old API version.
URL versioning — /v1/users, /v2/users — is the most common approach because it is explicit and visible. The API version is in the URL path, making requests easy to distinguish in server logs, easy to test in a browser, and easy to route to different backend handlers. The downside is that every endpoint annotation must include the version prefix, and changing the version requires updating all annotations. Header versioning — X-Api-Version: 2 added by an OkHttp interceptor — produces cleaner URLs and centralises the version in one place. The tradeoff is that the version is invisible in logs without additional configuration.
Defensive deserialization is the most important versioning protection on the client side. ignoreUnknownKeys = true in Kotlin Serialization prevents old app versions from crashing when the server adds new fields to responses. Default values on optional fields prevent crashes when the server removes a field. Together these allow the server to evolve the API by adding fields without breaking installed clients. For breaking changes that old clients genuinely cannot handle, return HTTP 426 Upgrade Required with a body pointing to the Play Store URL. The app should intercept this response at the repository level and trigger a flow that prompts the user to update — the update prompt is a better user experience than a generic error.
"The key insight about mobile API versioning: you cannot deprecate and remove an API endpoint until every old app version is gone from the field — which may be never. API versioning on mobile is about indefinite backward compatibility, not a clean deprecation cycle like web APIs."
34. Scenario: Your app needs to make network requests even when the user is offline — queue them and execute when connectivity is restored.
WorkManager is the right tool for deferred requests — it survives process death, respects network constraints, and retries automatically. Room is the queue.
// Pattern: Outbox — queue locally, sync when online // Step 1: Persist the pending request to Room @Entity(tableName = "pending_requests") data class PendingRequest( @PrimaryKey(autoGenerate = true) val id: Int = 0, val type: String, // "LIKE_POST", "FOLLOW_USER" val payload: String, // JSON serialised params val createdAt: Long = System.currentTimeMillis () ) // Step 2: Instead of calling API directly, save to DB suspend fun likePost(postId: String) { // Optimistic UI update immediately dao.updateLikeCount (postId, increment = 1) // Queue the API call pendingDao.insert (PendingRequest("LIKE_POST", """{"postId":"$postId"}""")) // Schedule sync worker WorkManager.getInstance (context).enqueue ( OneTimeWorkRequestBuilder<SyncWorker>() .setConstraints (Constraints(requiresNetwork = true)) .setExpedited (OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build () ) } // Step 3: Worker processes the queue when online @HiltWorker class SyncWorker @AssistedInject constructor(...) : CoroutineWorker(...) { override suspend fun doWork(): Result { val pending = pendingDao.getAll () for (req in pending) { when (req.type) { "LIKE_POST" -> {val params = Json.decodeFromString <LikeParams>(req.payload) api.likePost (params.postId) pendingDao.delete (req) // remove after success } } } return Result.success () } }
The outbox pattern enables offline writes: instead of attempting to POST directly to the API when the user takes an action, write the operation to a local Room database immediately and return success to the UI. The local write is fast and always succeeds regardless of connectivity. A background sync process reads unsynced operations from the database and sends them to the API when connectivity is available. From the user's perspective, the action succeeded instantly. The sync to the server happens in the background, invisibly.
Optimistic UI extends this pattern: update the local state immediately to reflect what the sync will produce, rather than waiting for server confirmation. When the user marks a to-do item complete, update the Room record to completed and refresh the UI immediately. The sync sends the update to the server in the background. If the server rejects the update — validation failure, conflict — roll back the local state and show an error. For most actions, the optimistic update is correct and no rollback is needed. The perceived responsiveness improvement is significant, especially on slow connections.
WorkManager is the right mechanism for the sync process. A OneTimeWorkRequest with a Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() constraint will only execute when the device has connectivity — it queues and waits if the device is offline when the action occurs. setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) asks WorkManager to run the sync as soon as connectivity becomes available, skipping the normal deferral window. Room as the outbox queue is essential for durability: if the app is killed between writing the local record and syncing it, the unsync'd operation survives in the database and is picked up on the next launch.
"This is the offline-first pattern for write operations. Instagram does this for likes — you tap the heart, the UI updates immediately, and the API call queues. If you lose signal, the like syncs silently when you reconnect. The user never sees a failure."
35. What is gzip compression in HTTP and how does OkHttp handle it?
Gzip compresses the response body before sending — a 100KB JSON response might compress to 15KB. OkHttp adds the Accept-Encoding header and decompresses responses automatically. You get this for free.
// OkHttp adds this header automatically to every request: // "Accept-Encoding: gzip" // This tells the server: "I can handle gzip compressed responses" // If server supports gzip: "Content-Encoding: gzip" in response // OkHttp automatically decompresses → you get plain JSON in responseBody.string() // Zero code needed — it just works // Typical compression ratios for API responses: // JSON: 60-80% reduction ({"name":"Alice","email":"alice@example.com"...} = very compressible) // HTML: 70-80% reduction // Images: 0-5% (already compressed — JPEG, PNG, WebP) // Binary: 0-20% // Verify gzip is working (check response headers): class CompressionLoggingInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val response = chain.proceed (chain.request()) val encoding = response.header ("Content-Encoding") val originalSize = response.header ("X-Uncompressed-Content-Length") // if server sends it Log.d ("Compression", "Encoding: $encoding, Original: $originalSize") return response } } // Request compression — compressing the request body (less common) // Useful when POSTing large JSON payloads class GzipRequestInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val original = chain.request() if (original.body == null) return chain.proceed (original) val gzipped = original.newBuilder () .header ("Content-Encoding", "gzip") .method (original.method, original.body!!.gzip ()) .build () return chain.proceed (gzipped) } }
OkHttp automatically requests gzip-compressed responses by adding Accept-Encoding: gzip to every request and transparently decompresses the response body before delivering it to your code. This happens with zero configuration. The decompressed response body is what your converter factory sees — Kotlin Serialization parses plain JSON, not compressed bytes. You never need to manually decompress anything. The only time to think about this is when using a network interceptor to inspect response bodies: the interceptor sees the raw bytes before OkHttp decompresses them, so add a network interceptor after OkHttp's own processing if you need to inspect decoded content.
JSON compresses exceptionally well because it contains highly repetitive text — field names repeat on every object in an array, common values like true, false, and short strings appear constantly. Gzip compression typically achieves 70–80% size reduction on API responses. A 200KB JSON response for a product list becomes 40–60KB after compression. On a mobile network where bandwidth is metered and latency matters, this translates directly to faster load times and lower data costs for users. Binary formats like Protobuf are smaller than compressed JSON in absolute terms, but compressed JSON is dramatically smaller than uncompressed JSON — the practical gap between compressed JSON and Protobuf is much smaller than the theoretical gap between uncompressed formats.
Image data is already compressed at the format level. JPEG uses DCT compression, PNG uses DEFLATE (the same algorithm as gzip), WebP uses its own compression. Running gzip on top of already-compressed data produces negligible size reduction and wastes CPU cycles on both the server and the client. Your image loading library (Coil, Glide) handles this correctly by not requesting gzip for image requests. Request compression — gzip compressing the POST/PUT body before sending — is rarely needed because most API request bodies are small. It is worth considering for bulk upload endpoints where large JSON payloads are sent to the server.
"Gzip is the biggest free performance win in networking — OkHttp handles it with zero effort. A product listing API returning 200 products as JSON might be 80KB uncompressed. With gzip it's 15KB. On a 3G connection that's the difference between 200ms and 50ms. Always ensure your server sends Content-Encoding: gzip."
36. Scenario: You need to call three APIs in parallel and merge results for a dashboard screen. How do you do this efficiently?
async/await with coroutineScope runs all three requests simultaneously — total time equals the slowest, not the sum. This is one of the clearest wins of coroutines over callbacks.
// Sequential — BAD: 300ms + 250ms + 200ms = 750ms total suspend fun loadDashboardSequential(): Dashboard { val user = api.getUser () // 300ms val orders = api.getOrders () // 250ms val products = api.getProducts () // 200ms return Dashboard(user, orders, products) } // Parallel — GOOD: max(300, 250, 200) = 300ms total suspend fun loadDashboardParallel(): Dashboard = coroutineScope { val userDeferred = async { api.getUser () } val ordersDeferred = async { api.getOrders () } val productsDeferred = async { api.getProducts () } // All three requests are in flight right now Dashboard( user = userDeferred.await (), orders = ordersDeferred.await (), products = productsDeferred.await () ) } // If any one fails → coroutineScope cancels the others → exception propagates // Parallel with independent error handling (one failure doesn't kill others) suspend fun loadDashboardSafe(): Dashboard = coroutineScope { val userD = async {runCatching { api.getUser () } } val ordersD = async {runCatching { api.getOrders () } } val productsD = async {runCatching { api.getProducts () } } Dashboard( user = userD.await ().getOrNull (), // null on error orders = ordersD.await ().getOrNull (), products = productsD.await ().getOrNull () ) // Partial dashboard shown even if some calls fail } // In ViewModel fun loadDashboard() { viewModelScope.launch { _state.value = UiState.LoadingrunCatching { repo.loadDashboardParallel () } .onSuccess { _state.value = UiState.Success (it) } .onFailure { _state.value = UiState.Error (it.message !!) } } }
Sequential API calls serialize wait time: three 250ms requests take 750ms. Parallel calls reduce this to the duration of the slowest single request — 250ms. The Kotlin coroutines API makes parallelism clean and structured with async and await. Inside a coroutineScope block, val userDeferred = async { userApi.getUser(id) } starts the request without blocking. Multiple async calls can be launched before any await is called, putting all requests in flight simultaneously. Each deferred.await() suspends the current coroutine until that specific result is ready.
coroutineScope provides structured concurrency for the parallel calls. If any one of the async blocks throws an exception, the scope cancels all other in-flight async blocks and re-throws the exception to the caller. This fail-fast behaviour is correct for screens that require all data to be present before rendering — if the user data fails, showing the user's orders in isolation is meaningless. The entire operation fails atomically, and the error is handled once at the call site.
When partial success is acceptable — show the data that loaded even if one API failed — use supervisorScope instead of coroutineScope, or wrap individual async calls in runCatching. supervisorScope does not cancel sibling coroutines when one fails: each async block's failure is independent. Combined with runCatching { deferred.await() } on each result, you can accumulate successes and failures separately and present what was successfully loaded while showing a partial-error state for what failed. This is appropriate for dashboard screens where different sections come from different APIs and each section can be shown independently.
"This is the most impactful coroutine pattern for UX. A dashboard with 3 serial API calls at 300ms each = 900ms load. With async/await they run in parallel = 300ms. 600ms faster, zero extra complexity. I use this pattern on every screen that needs data from multiple endpoints."
37. What is an idempotency key and when do you need it?
An idempotency key is a unique ID sent with non-idempotent requests (like payment) so the server can recognise and deduplicate retries. Without it, a network timeout could cause the user to be charged twice.
// The problem: payment request + network timeout // 1. App sends POST /payments {amount: 1000} // 2. Server processes payment → charges card ✅ // 3. Network drops → app never receives 200 OK // 4. App retries → POST /payments {amount: 1000} again // 5. Server processes again → charges card AGAIN ❌ double charge! // Solution: idempotency key suspend fun makePayment(amount: Int, orderId: String): PaymentResult { // Generate a unique key for this payment attempt // Use orderId + timestamp so retries for SAME order use same key val idempotencyKey = "payment-$orderId" return api.charge ( amount = amount, idempotencyKey = idempotencyKey // sent as header ) } // API interface @POST("payments") suspend fun charge( @Body request: PaymentRequest, @Header("Idempotency-Key") idempotencyKey: String ): PaymentResult // Server behaviour: // First request with key "payment-order-123" → process + store key + return result // Second request with SAME key "payment-order-123" → return SAME result, don't charge again // Third request → same result again (usually cached 24 hours) // UUID as idempotency key (for when there's no natural unique ID): val idempotencyKey = UUID.randomUUID ().toString () // Generate ONCE, persist to SharedPreferences // Reuse on retry, generate new one for a fresh attempt
The idempotency key problem arises when a POST request times out. The network request left the device — the server may or may not have received and processed it. If you retry and the server did process the first request, you now have two orders, two charges, or two emails. POST is not idempotent by definition, so simply retrying introduces duplicates. This is particularly severe for payment processing: a timeout on a payment POST, retried once, potentially charges the user twice.
The idempotency key pattern solves this at the application level. Before sending the request, generate a stable unique identifier for the logical operation and include it as a header: Idempotency-Key: <uuid>. The key should be deterministic for the same logical operation — for an order placement, use a hash of the order contents or a UUID stored with the pending order in Room. When the server receives the request, it stores the key and the response. If the same key arrives again (a retry), the server returns the stored response without reprocessing. The client receives the correct response on the retry without any duplicate side effect.
The server's idempotency key cache typically has a TTL of 24 hours — long enough to handle any reasonable retry window, short enough to not store results forever. The Android implementation stores the pending idempotency key in Room alongside the operation: PlacedOrder(orderId, idempotencyKey, status = PENDING). On retry, read the same idempotency key from Room and send it. On success, mark the order as synced. On definitive failure (4xx), mark as failed. This pattern is required for any operation with real-world side effects — payments, order placement, email or SMS sending, account creation — and recommended for any expensive or non-repeatable server operation.
"Stripe, Razorpay, and PayPal all support idempotency keys. Without it, mobile payment is fundamentally unsafe — the user always has a brief window where a network drop could cause a double charge. The key rule: generate the idempotency key BEFORE making the request, persist it, and reuse on retry."
38. Scenario: How do you use MockWebServer to write tests that simulate specific HTTP responses including errors, delays, and timeouts?
MockWebServer from OkHttp runs a real HTTP server locally during tests. You pre-program exactly what responses it returns — making network tests deterministic, fast, and offline-capable.
// testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") class UserRepositoryTest { private val server = MockWebServer() private lateinit var repo: UserRepository @Before fun setUp() { server.start () val retrofit = Retrofit.Builder() .baseUrl (server.url ("/")) // point to local server .addConverterFactory (GsonConverterFactory.create ()) .build () repo = UserRepositoryImpl(retrofit.create (UserApi::class.java)) } @After fun tearDown() { server.shutdown () } // Test: successful response @Test fun getUser_success() = runTest { server.enqueue (MockResponse() .setResponseCode (200) .setBody ("""{"id":"1","name":"Alice"}""") .setHeader ("Content-Type", "application/json")) val user = repo.getUser ("1") assertEquals("Alice", user.name) } // Test: 401 Unauthorized @Test fun getUser_401_throwsAuthException() = runTest { server.enqueue (MockResponse().setResponseCode (401))assertThrows <AuthException> { repo.getUser ("1") } } // Test: simulate slow response (timeout) @Test fun getUser_timeout_throwsIOException() = runTest { server.enqueue (MockResponse() .setBodyDelay(5, TimeUnit.SECONDS) // longer than readTimeout . setResponseCode (200).setBody ("""{}"""))assertThrows <SocketTimeoutException> { repo.getUser ("1") } } // Test: verify request was sent correctly @Test fun getUser_sendsCorrectHeaders() = runTest { server.enqueue (MockResponse().setResponseCode (200).setBody ("""{"id":"1","name":"Alice"}""")) repo.getUser ("1") val request = server.takeRequest () // inspect what was sent assertEquals("/users/1", request.path) assertNotNull(request.getHeader ("Authorization")) } }
MockWebServer (from OkHttp's mockwebserver artifact) starts a real HTTP server on localhost within the test process. Your production OkHttp and Retrofit stack connects to it as if it were a real backend. The difference from fake API implementations is that MockWebServer tests exercise the entire stack: Retrofit's annotation processing, OkHttp's request building, the TLS configuration, the converter factory's JSON parsing, and the error handling logic. A bug in a @SerialName annotation or a missing ignoreUnknownKeys configuration will surface in a MockWebServer test but not in a fake API test that bypasses deserialization.
server.enqueue(MockResponse()) pre-programs responses in order. The first request the server receives gets the first enqueued response, the second gets the second, and so on. Common patterns: enqueue a 200 with a JSON body for the happy path, enqueue a 401 followed by a 200 to test the token refresh interceptor, or enqueue three 503s followed by a 200 to test retry logic. MockResponse().setBodyDelay(2000, TimeUnit.MILLISECONDS) simulates a slow server — essential for testing timeout handling without actually sleeping for real seconds in the test.
server.takeRequest() returns the RecordedRequest for the most recent HTTP call received by the server — the actual bytes your app sent. This lets you assert that the correct path was called (assertThat(request.path).isEqualTo("/v2/users/123")), that required headers are present (assertThat(request.getHeader("Authorization")).startsWith("Bearer ")), and that the request body contains the expected JSON. This verification direction — asserting what the app sent — is something no fake API implementation can provide. MockWebServer tests run completely offline with no real network dependency, making them deterministic and CI-safe.
"MockWebServer tests are integration tests — they go through the actual Retrofit parsing, OkHttp interceptors, and response mapping. I use them specifically to test: does my repository correctly parse this JSON? Does it handle a 401 by throwing the right exception? Does my auth interceptor add the right header? Things a FakeApi can't verify."
39. What is the difference between PUT and PATCH? When do you use each in a REST API?
PUT replaces the entire resource. PATCH updates only the fields you send. For most mobile apps, PATCH is preferable — you only send what changed, saving bandwidth and avoiding accidental data loss.
// User resource: { id, name, email, phone, address, avatar, bio, ... } // PUT — replace the ENTIRE resource // Must send ALL fields, even unchanged ones @PUT("users/{id}") suspend fun replaceUser( @Path("id") id: String, @Body user: UserRequest // ALL fields required ): User // PUT /users/123 with { name, email, phone, address, avatar, bio } // If you omit 'phone' → server sets phone = null (data loss!) // Use when: you want to replace the entire resource semantically // PATCH — update ONLY the fields you send // Unmentioned fields remain unchanged on the server @PATCH("users/{id}") suspend fun updateUser( @Path("id") id: String, @Body update: UserPatch // only fields to change ): User @Serializable data class UserPatch( val name: String? = null, // null = "don't change this" val bio: String? = null ) // PATCH /users/123 with { "name": "Alice Updated" } // → only name changes, email/phone/address untouched // Serialization: only include non-null fields in JSON val json = Json { encodeDefaults = false } // null fields omitted from JSON // UserPatch(name = "Alice") → {"name":"Alice"} — bio omitted // Real-world usage: // Profile edit screen → PATCH (user changed only name, don't touch others) // Account creation → PUT (replacing the empty record with full data) // Toggle feature flag → PATCH { "featureEnabled": true }
PUT replaces a resource completely with the representation in the request body. If the current user resource has fields name, email, phone, and address, and you PUT a body containing only name and email, the server replaces the entire resource — phone and address are now absent or null. The client must send all fields it wants to preserve, including fields it did not change. This makes PUT dangerous in mobile contexts where you might only know about the fields displayed in the current screen.
PATCH applies a partial update. The client sends only the fields it wants to change, and the server merges them with the existing resource. Sending {"name": "Alice"} via PATCH changes the name without affecting phone, address, or any other field. This is the appropriate method for "save edits" operations in a mobile form: the user edits two of ten fields, and you send only those two changes. It is bandwidth-efficient (smaller payload), safer (no accidental data loss from omission), and handles concurrent edits more gracefully — two users editing different fields simultaneously both succeed rather than the second PUT overwriting the first.
Kotlin Serialization's encodeDefaults = false configuration makes PATCH bodies clean: fields with default values (null for nullable fields, 0 for integers) are omitted from the JSON output. A data class UserPatchRequest(val name: String? = null, val email: String? = null) with only name set serializes to {"name":"Alice"} — the null email field is omitted, producing a minimal PATCH body. PATCH is idempotent in practice: sending the same PATCH twice results in the same server state. Both PUT and PATCH have @PUT and @PATCH Retrofit annotations — use them semantically correctly rather than always defaulting to PUT.
"The classic PUT mistake on mobile: user edits their name, app sends PUT with only the name field, server sets all other fields to null. Data wipe. PATCH exists specifically for partial updates — always use it for profile editing and settings. Set encodeDefaults=false in Kotlin Serialization so null fields are excluded from the request body."
40. What is gRPC? How does it compare to REST and when would you use it in an Android app?
gRPC is Google's RPC framework using Protocol Buffers (binary format) over HTTP/2. It's significantly faster and more efficient than REST+JSON, but more complex to set up. Best for internal microservices or high-performance data-heavy apps.
// REST+JSON vs gRPC+Protobuf comparison // Same user object: // JSON: {"id":"123","name":"Alice","email":"alice@ex.com"} = 47 bytes // Protobuf: binary encoded = ~15 bytes (3x smaller) // Protobuf also parses 5-10x faster than JSON // Protocol Buffer definition (.proto file) // syntax = "proto3"; // message User { string id = 1; string name = 2; string email = 3; } // service UserService { rpc GetUser (GetUserRequest) returns (User); } // Android gRPC setup (generated code from .proto) // implementation("io.grpc:grpc-android:1.64.0") // implementation("io.grpc:grpc-kotlin-stub:1.4.0") val channel = ManagedChannelBuilder .forAddress ("api.example.com", 443) .useTransportSecurity () .build () val stub = UserServiceGrpcKt.UserServiceCoroutineStub(channel) val user = stub.getUser (getUserRequest { id = "123" }) // type-safe, binary // gRPC streaming — server streams responses val userFlow: Flow<User> = stub.watchUser(watchRequest { id = "123" }) userFlow. collect { update ->println (update.name) } // When gRPC makes sense on Android: // ✅ Internal microservices backend (not public API) // ✅ High-frequency data (stock prices, real-time metrics) // ✅ Large data transfers (ML model weights, binary data) // ✅ Bidirectional streaming // ❌ Public API used by 3rd parties (REST is standard) // ❌ Small team / prototype (setup overhead not worth it)
gRPC is an RPC framework that uses Protocol Buffers (Protobuf) as its serialization format and HTTP/2 as its transport. Protobuf is a binary format: instead of human-readable JSON key-value pairs, it encodes data as field-tagged binary records. The result is typically 3x smaller than equivalent JSON — critical for high-frequency APIs or bandwidth-constrained mobile scenarios. Parsing binary data is also significantly faster than JSON parsing (5–10x in benchmarks), which matters for large responses on low-end devices with limited CPU.
The type system is defined in .proto files: message User { string id = 1; string name = 2; }. The protoc compiler generates Kotlin/Java client and server stubs from these definitions. The generated code handles serialization and deserialization — you work with typed objects, not JSON strings. This eliminates an entire category of bugs: field name typos, missing @SerialName annotations, null handling mismatches. The API contract is expressed in the .proto file and enforced by the compiler on both client and server. Streaming is first-class: gRPC supports server-streaming (server sends multiple responses to one request), client-streaming, and bidirectional streaming, all over a single HTTP/2 connection.
The trade-offs are real. Binary data is not human-readable — you cannot inspect a gRPC request with curl or a browser. Debugging requires gRPC-specific tools like grpc-ui or grpcurl. The Android setup is more complex than adding a Retrofit dependency: proto plugin in Gradle, code generation configuration, gRPC channel setup. The ecosystem is less mature than REST for Android — fewer tutorials, less community knowledge, fewer compatible tooling integrations. gRPC is the right choice for internal microservice communication, real-time streaming APIs, or performance-critical mobile data feeds. It is not the right choice for a simple CRUD app where REST with Kotlin Serialization is simpler to build, debug, and maintain.
"I'd choose gRPC for internal high-throughput services — like a real-time stock feed or ML inference. REST+JSON for anything public-facing or consumer-oriented. The debuggability difference matters: with REST I can test any endpoint in a browser. With gRPC I need specific tooling. That friction adds up in a small team."
41. What is CORS and does it affect Android apps?
CORS (Cross-Origin Resource Sharing) is a browser security policy that restricts web pages from making requests to a different domain than the one that served the page. Android apps are not browsers -- they have no CORS enforcement. An Android OkHttp call to any domain always works regardless of CORS headers.
// CORS does NOT affect Android -- OkHttp is not a browser, no origin concept val response = okHttpClient.newCall(Request.Builder(). url("https://api.otherdomain.com/data") // works fine, no CORS check .build()).execute() // CORS only matters in WebView -- the embedded browser DOES enforce it val settings = webView.settings settings.javaScriptEnabled = true // JavaScript inside WebView making cross-origin XHR → CORS applies // Server must return: Access-Control-Allow-Origin: https://yourapp.com
CORS (Cross-Origin Resource Sharing) is a browser security mechanism. It does not apply to native Android code. OkHttp makes HTTP requests without any concept of "origin" — it simply sends bytes and receives bytes. No Origin header is sent, no preflight OPTIONS request occurs, and the server's Access-Control-Allow-Origin response headers are completely ignored. Native Android apps can make HTTP requests to any URL they have network permission for, regardless of what the server's CORS policy says.
CORS exists to protect users from malicious websites. When a user is logged into bank.com and visits a malicious site, the browser prevents that site's JavaScript from making authenticated requests to bank.com using the user's cookies. Without CORS, malicious sites could steal money or data by triggering authenticated requests on behalf of the user. The same-origin policy restricts this; CORS is the mechanism by which servers explicitly opt into cross-origin requests from trusted web origins. This threat model does not apply to native apps: there are no cookies shared across origins, and the app's code is explicit rather than dynamically loaded from an attacker's server.
The exception is WebView. JavaScript running inside a WebView is subject to the same browser origin policy as a desktop browser. A WebView loading content from https://myapp.com that attempts to fetch from https://api.otherdomain.com via JavaScript will be blocked by CORS if the API server does not permit the origin. If you embed a web app in a WebView that needs to call APIs on a different domain, those APIs must have appropriate CORS headers — or you bridge the calls through native Kotlin code where CORS does not apply. This distinction is a common interview gotcha: candidates often assume CORS is a universal HTTP constraint when it is purely a browser enforcement mechanism.
"Junior devs sometimes spend hours trying to 'fix CORS' in their Android app. CORS only applies to browsers. Your Retrofit calls will never have a CORS error — the server accepts them regardless. If you're seeing CORS errors, it's either in a WebView or you're testing in a browser's fetch API."
42. Scenario: Your API returns a list of 1000 items but your screen only shows 20 at a time. How do you implement cursor-based pagination vs offset pagination?
Offset pagination (page=1&limit=20) is simple but has problems with real-time data. Cursor-based pagination uses a pointer to the last seen item — more reliable for feeds that change while the user scrolls.
// OFFSET PAGINATION — page numbers // GET /posts?page=3&limit=20 // Server: SELECT * FROM posts ORDER BY date DESC LIMIT 20 OFFSET 60 // ✅ Simple to implement, easy to understand // ❌ New posts shift items — page 3 may have duplicates from page 2 // ❌ OFFSET is slow on large tables (DB scans all previous rows) interface PostApi { @GET("posts") suspend fun getPosts(@Query("page") page: Int, @Query("limit") limit: Int = 20): PagedPosts } // CURSOR PAGINATION — pointer to last seen item // GET /posts?after=post_id_xyz&limit=20 // Server: SELECT * FROM posts WHERE id > 'post_id_xyz' ORDER BY id LIMIT 20 // ✅ No duplicates — new posts don't shift the cursor // ✅ O(log n) with index — fast even on billions of rows // ❌ Can't jump to page 5 directly // ❌ Slightly more complex to implement @Serializable data class CursorPagedPosts( val posts: List<Post>, val nextCursor: String? = null // null = no more pages ) // PagingSource with cursor class PostPagingSource(private val api: PostApi) : PagingSource<String, Post>() { override suspend fun load(params: LoadParams<String>): LoadResult<String, Post> { return try { val cursor = params.key // null on first load val response = api.getPosts (after = cursor, limit = params.loadSize) LoadResult.Page ( data = response.posts, prevKey = null, // no going back in cursor pagination nextKey = response.nextCursor // null = last page ) } catch (e: Exception) { LoadResult.Error (e) } } override fun getRefreshKey(state: PagingState<String, Post>) = null }
Offset pagination uses a page number or offset value: GET /posts?page=3&limit=20. The server calculates OFFSET 40 LIMIT 20 and returns items 41–60. This is simple to implement and allows random access — the client can jump to any page. The problems surface with real-time data: if five new posts are added between the user loading page 1 and page 2, the offset shifts and some posts appear twice or are skipped. Additionally, large offsets are slow: OFFSET 10000 LIMIT 20 requires the database to scan and discard 10,000 rows before returning 20, which degrades with data size regardless of indexing.
Cursor pagination uses a pointer into the data set — typically the ID or timestamp of the last item received: GET /posts?after=post_id_xyz&limit=20. The server fetches items where id > post_id_xyz LIMIT 20 using an indexed column. This is O(log n) regardless of cursor position. New items inserted before the cursor do not affect the next page — the cursor points to a specific record, not a numeric position. No duplicates, no skipped items, consistent performance. Instagram, Twitter, LinkedIn, and all social feed APIs use cursor pagination because feed consistency across page loads matters more than random access to arbitrary pages.
Paging 3's PagingSource key type determines the pagination strategy. PagingSource<Int, Post> uses integer page numbers — offset pagination. PagingSource<String, Post> uses string cursor IDs — cursor pagination. The load() function receives the current key in params.key (null on the first load), uses it to construct the API request, and returns LoadResult.Page(data = posts, prevKey = null, nextKey = response.nextCursor). Returning nextKey = null signals end of data. For cursor pagination, extract the next cursor from the API response and pass it as nextKey — Paging 3 passes it back as params.key on the next load call.
"For a social feed with continuous new content, cursor pagination is essential. With offset: user scrolls to page 3, a new post is added, all subsequent pages shift by one — user sees duplicate posts. With cursor: 'give me posts after ID xyz' — new posts don't affect existing cursors at all."
43. How do you handle network state changes (going offline/online) in an Android app?
Observing network connectivity lets your app adapt in real time — showing offline banners, pausing sync, resuming operations when connectivity returns. The modern approach uses ConnectivityManager with a Flow.
// Observe network connectivity as a Flow class NetworkMonitor @Inject constructor( @ApplicationContext private val context: Context ) { val isOnline: Flow<Boolean> =callbackFlow { val cm = context.getSystemService (ConnectivityManager::class.java) val callback = object : NetworkCallback() { override fun onAvailable(network: Network) {trySend (true) } override fun onLost(network: Network) {trySend (false) } override fun onUnavailable() {trySend (false) } } val request = NetworkRequest.Builder() .addCapability (NetworkCapabilities.NET_CAPABILITY_INTERNET) .build () cm.registerNetworkCallback (request, callback) // Emit current state immediatelytrySend (cm.activeNetwork != null)awaitClose { cm.unregisterNetworkCallback (callback) } }.distinctUntilChanged () // don't re-emit same state } // In ViewModel — react to connectivity changes @HiltViewModel class HomeViewModel @Inject constructor( private val networkMonitor: NetworkMonitor, private val repo: HomeRepository ) : ViewModel() { val isOffline = networkMonitor.isOnline .map { !it } .stateIn (viewModelScope, SharingStarted.WhileSubscribed (5000), false) init { viewModelScope.launch { networkMonitor.isOnline.filter { it }.collect { repo.sync () // auto-sync when connection restored } } } }
The modern connectivity monitoring API is ConnectivityManager.registerNetworkCallback(). The deprecated NetworkInfo API (removed in API 29) and the CONNECTIVITY_CHANGE broadcast (throttled and eventually restricted for background apps) should not be used. NetworkCallback receives onAvailable() when a network satisfying the request criteria connects and onLost() when all qualifying networks disconnect. The NetworkRequest specifies the capabilities — NET_CAPABILITY_INTERNET for a network with internet access, TRANSPORT_WIFI if you need specifically Wi-Fi. Register with requestNetwork rather than registerNetworkCallback if you need to validate that the network actually has internet connectivity, not just a local connection.
callbackFlow bridges the callback-based API into a Kotlin Flow: inside the builder, create and register the callback, then call awaitClose { unregisterNetworkCallback(callback) } to clean up when the flow is cancelled. Each onAvailable call emits true, each onLost emits false. .distinctUntilChanged() downstream prevents redundant emissions — when the system reports onAvailable multiple times for the same network state, only the first is emitted to collectors. This flow is exposed from a @Singleton repository and collected in the ViewModel as a StateFlow.
The reconnect trigger pattern is: connectivityFlow.filter { isConnected -> isConnected }.collect { triggerSync() }. This fires the sync operation exactly when connectivity transitions from offline to online — not on every connectivity event, not when already online. The ViewModel exposes an isOffline: StateFlow<Boolean> derived from the connectivity flow. In Compose, val isOffline by viewModel.isOffline.collectAsStateWithLifecycle() drives an offline banner that appears when connectivity is lost and disappears when it returns. collectAsStateWithLifecycle() automatically pauses collection when the composable is not in the foreground, preventing unnecessary background work.
"The key pattern: combine offline-first data (Room as source of truth) with NetworkMonitor for sync. When isOnline emits true, trigger a repo.sync(). Users see cached data immediately, and fresh data flows in when connectivity is restored. They never see a loading spinner just because of network state."
44. Scenario: Your app has sensitive data in API responses. How do you prevent it from being captured by proxies or network inspection tools?
Tools like Charles Proxy work by installing a custom CA on the device. SSL pinning defeats this. For maximum security — combine SSL pinning, certificate transparency, and detection of proxy/rooted environments.
// Layer 1: SSL Pinning (blocks most proxy tools) val pinner = CertificatePinner.Builder() .add ("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") .add ("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // backup .build () // Layer 2: Detect proxy (Charles, Fiddler, mitmproxy) fun isProxySet(): Boolean { val proxyHost = System.getProperty ("http.proxyHost") val proxyPort = System.getProperty ("http.proxyPort") return !proxyHost.isNullOrBlank () && proxyPort != null } // Layer 3: Detect rooted device (jailbroken = custom CAs possible) fun isRooted(): Boolean { return File("/system/app/Superuser.apk").exists () || File("/sbin/su").exists () || Build.TAGS?.contains("test-keys") == true } // Layer 4: Network Security Config — block cleartext and user-added CAs <network-security-config> <base-config cleartextTrafficPermitted="false"> <trust-anchors> <certificates src="system" /> <!-- only system CAs, not user-installed --> <!-- NOT adding src="user" -- Charles requires user-installed CA! --> </trust-anchors> </base-config> </network-security-config> // This alone blocks Charles on non-rooted devices without needing cert pinning // Combine in security check: fun performSecurityCheck(): SecurityStatus = when {isRooted () -> SecurityStatus.ROOTED_DEVICEisProxySet () -> SecurityStatus.PROXY_DETECTED else -> SecurityStatus.SECURE }
Traffic interception prevention defends against both debugging tools (Charles, mitmproxy used by QA and security researchers) and malicious MITM attacks. SSL pinning is the strongest defence: the app rejects any TLS certificate that does not match the pinned public key hash, regardless of which CA signed it. Proxy tools like Charles install their own CA certificate on the device and issue their own certificates for your domain — valid according to the system trust store, but not matching your pin. A correctly pinned app throws an SSLPeerUnverifiedException and refuses to connect through the proxy.
Network Security Config is a softer defence that works on non-rooted devices. Android 7+ does not trust user-installed CA certificates for app connections by default — only system CAs are trusted. Charles requires installing its CA as a user certificate. Without user CA trust in the NSC (which is the default for apps targeting API 24+), Charles fails to intercept traffic without SSL pinning being needed at all. In the debug build variant, explicitly add user CA trust to allow the team to debug with Charles: in the release NSC, do not include user CA trust. This is the minimum baseline — SSL pinning adds a second layer.
Proxy detection and root detection add additional layers. System proxy detection: System.getProperty("http.proxyHost") returns a non-null value when a proxy is configured. If detected, skip or delay sensitive operations with a warning. Root detection libraries check for indicators of root access — modified system partition, su binary, known root management apps. Rooted devices can bypass certificate stores and install certificates as system CAs, bypassing even SSL pinning. The appropriate response is not to crash the app (which creates a poor user experience and can be patched by attackers) but to warn the user and potentially disable features that handle particularly sensitive data. No single defence is perfect; layering them raises the cost of a successful attack.
"The simplest defense against Charles Proxy on non-rooted devices: don't add src='user' to your Network Security Config trust anchors. Charles requires installing a user CA — without trusting user CAs, Charles can't intercept. Add SSL pinning on top for rooted device protection. Neither is 100% — a determined attacker on a rooted device can always find a way."
45. What is OkHttp's EventListener? How do you use it to monitor network performance?
EventListener gives you detailed timing callbacks for every phase of an HTTP request — DNS lookup, TCP connect, TLS handshake, request write, response read. Perfect for performance monitoring without third-party APM tools.
class NetworkEventListener : EventListener() { private val callStart = AtomicLong() override fun callStart(call: Call) { callStart.set (System.currentTimeMillis ()) } override fun dnsStart(call: Call, domainName: String) { Log.d ("Net", "DNS lookup started: $domainName") } override fun dnsEnd(call: Call, domainName: String, inetAddressList: List<InetAddress>) { Log.d ("Net", "DNS resolved to: ${inetAddressList.first()}") } override fun connectStart(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy) { Log.d ("Net", "TCP connect started") } override fun secureConnectEnd(call: Call, handshake: Handshake?) { Log.d ("Net", "TLS handshake complete: ${handshake?.tlsVersion}") } override fun responseHeadersEnd(call: Call, response: Response) { val ttfb = System.currentTimeMillis () - callStart.get () Log.d ("Net", "Time to first byte: ${ttfb}ms") } override fun callEnd(call: Call) { val total = System.currentTimeMillis () - callStart.get () val url = call.request().url.toString() Log.d ("Net", "[$url] Total: ${total}ms") analytics.recordNetworkCall (url, total) // report to your analytics } } // Wire to OkHttp val client = OkHttpClient.Builder() .eventListenerFactory (EventListener.factory (NetworkEventListener())) .build () // Events fired (in order for a fresh connection): // callStart → dnsStart → dnsEnd → connectStart → connectEnd // → secureConnectStart → secureConnectEnd → requestHeadersStart // → requestBodyStart → requestBodyEnd → responseHeadersStart // → responseHeadersEnd → responseBodyStart → responseBodyEnd → callEnd
OkHttp's EventListener provides granular hooks into every phase of an HTTP call's lifecycle: callStart, dnsStart/dnsEnd, connectStart/connectEnd, secureConnectStart/secureConnectEnd, requestHeadersStart/requestHeadersEnd, requestBodyStart/requestBodyEnd, responseHeadersStart/responseHeadersEnd, responseBodyStart/responseBodyEnd, callEnd/callFailed. By recording timestamps at each phase, you can decompose the latency of any request into its constituent parts: DNS resolution, TCP connection, TLS negotiation, time to first byte, body transfer.
TTFB (Time to First Byte) — the duration from callStart to responseHeadersEnd — is the most actionable performance metric. It captures everything the server is responsible for: processing the request, querying databases, running business logic. A high TTFB points to server-side slowness. A high connectEnd - connectStart points to network or TLS overhead. Connection reuse is visible by observing that connectStart and connectEnd are not called at all — a pooled connection skips the TCP handshake entirely. When pooling is working, most requests should show no connect time.
EventListener.Factory creates a new EventListener instance per HTTP call, allowing per-request state without thread-safety concerns. In production, use the factory to create listeners that capture timings and emit them as analytics events: tag each measurement with the request URL or endpoint name, and send them to your analytics system (batched, of course) for aggregate analysis. This surfaces slow endpoints that degrade user experience but do not cause explicit errors — latency issues are harder to detect than failures, and event-listener instrumentation is how you find them systematically. Apply the factory with OkHttpClient.Builder().eventListenerFactory(MyTimingListenerFactory()).
"EventListener is how you build your own lightweight APM for networking. I use it to track TTFB and total request time per endpoint, then report to Firebase Analytics. When a specific API endpoint becomes slow, it shows up in our dashboards before users file a complaint."
46. Scenario: A junior dev pushed code that makes network calls on the main thread. How do you detect and prevent this?
Main-thread network calls cause ANR (Application Not Responding) errors. They're detectable at runtime with StrictMode, preventable with architecture (suspend functions), and findable in code review with linting.
// DETECT: StrictMode in debug builds class MyApp : Application() { override fun onCreate() { super.onCreate () if (BuildConfig.DEBUG) { StrictMode.setThreadPolicy ( StrictMode.ThreadPolicy.Builder() .detectNetwork () // crash on network in main thread .detectDiskReads () // crash on disk IO in main thread .penaltyDeath () // crash immediately (don't just log) .build () ) } } } // PREVENT: Retrofit suspend functions run on background thread automatically interface UserApi { @GET("users") suspend fun getUsers(): List<User> // suspend = background thread ✅ @GET("users") fun getUsersBlocking(): Call<List<User>> // if you call .execute() = main thread ❌ } // In Repository — withContext ensures IO thread even if called from main suspend fun getUsers(): List<User> = withContext(Dispatchers.IO) { api.getUsers () // redundant for Retrofit suspend funs, but explicit is safe } // FIND IN CODE REVIEW: look for .execute() on main thread // ❌ Bad patterns to grep for: // api.getUsers().execute() — blocks current thread // runBlocking { api.getUsers() } — in Activity.onCreate()? main thread block! // Custom lint rule to catch blocking calls: // class NetworkOnMainThreadDetector : Detector() — checks for .execute() usage
Android throws a NetworkOnMainThreadException if any network I/O is attempted on the main thread — this has been a hard runtime error since API 11. The main thread runs the UI and input processing; blocking it on network I/O freezes the UI and triggers an ANR (Application Not Responding) dialog if blocked for more than 5 seconds. StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().detectNetwork().penaltyDeath().build()) in debug builds makes violations crash immediately with a clear stack trace, rather than being silently ignored or detected only intermittently. Enable this in your Application's debug onCreate and you will catch violations instantly during development.
Retrofit's suspend functions are safe by design: they dispatch work to OkHttp's internal dispatcher thread pool and suspend the calling coroutine without blocking its thread. The main thread is free while the request is in flight. The dangerous patterns are call.execute() — Retrofit's synchronous API — which blocks the calling thread entirely, and must only be called from a background thread; and runBlocking { api.getUser(id) } called on the main thread, which defeats the non-blocking nature of coroutines and blocks the main thread for the duration of the network call.
Code review is the systematic defence against these patterns. Grep for .execute() in Kotlin files: any call to a Retrofit Call.execute() that is not explicitly on a background dispatcher or inside a withContext(Dispatchers.IO) block is suspect. Grep for runBlocking in Activity, Fragment, and ViewModel files: runBlocking on the main thread is almost always wrong. The correct pattern for all new code is exclusively suspend functions called from coroutines launched in viewModelScope or lifecycleScope — both of which use the main dispatcher but correctly handle suspension for background operations.
"I add StrictMode.penaltyDeath() in the debug Application class on day one of every project. It's the most effective tool against accidental main-thread IO — the app crashes immediately in development, so the mistake is caught before code review. penaltyLog() is too easy to miss."
47. How do you send a custom User-Agent header with Retrofit and why is it useful?
User-Agent identifies your app to the server. OkHttp sends a default one, but a custom User-Agent lets your server analytics distinguish app versions, platforms, and even feature flags — without changing API contracts.
// OkHttp default User-Agent: // "okhttp/4.12.0" — not very informative // Custom User-Agent via interceptor — added to ALL requests automatically class UserAgentInterceptor(@ApplicationContext private val ctx: Context) : Interceptor { private val userAgent: String by lazy { val appName = ctx.getString (R.string.app_name) val versionName = BuildConfig.VERSION_NAME val versionCode = BuildConfig.VERSION_CODE val platform = "Android ${Build.VERSION.RELEASE}" val device = "${Build.MANUFACTURER} ${Build.MODEL}" "$appName/$versionName ($versionCode) $platform; $device" // "MyApp/2.1.0 (210) Android 14; Google Pixel 8" } override fun intercept(chain: Interceptor.Chain): Response = chain.proceed (chain.request().newBuilder () .header ("User-Agent", userAgent) .build ()) } // What the server can now do with this information: // ✅ Analytics: "70% of API calls from version 2.1.0, 30% from 2.0.3" // ✅ Deprecation: return 410 Gone for versions < 1.5.0 with forced update // ✅ Feature flags: enable new features only for version >= 2.0.0 // ✅ Bug tracking: correlate server errors with specific app versions // ✅ A/B testing: different responses for different app builds // Combine with X-App-Build header for additional context: // X-App-Build: debug / release / staging // X-Request-Id: UUID per request (for distributed tracing)
Custom request headers communicate metadata about the client to the server without polluting the URL. The User-Agent header is the standard way to identify your app: MyApp/2.5.1 Android/14 OkHttp/4.12.0. The server can use the app version to route to different API implementations, enforce minimum version requirements, or tailor response format to the client's capabilities. Without a meaningful User-Agent, server logs show only OkHttp's default user agent, making it impossible to correlate requests with app versions in production analytics.
Headers that should be present on every request belong in an OkHttp interceptor, not as @Header annotations on individual Retrofit methods. An interceptor approach is DRY: chain.proceed(request.newBuilder().header("User-Agent", userAgent).header("X-App-Version", appVersion).build()) adds the headers to every request from every API interface without any per-endpoint configuration. The User-Agent string is built once in the module and cached — val userAgent by lazy { "${context.packageName}/${BuildConfig.VERSION_NAME} Android/${Build.VERSION.RELEASE}" } — no string concatenation on each request.
Distributed tracing headers improve debuggability across the full request lifecycle. Adding X-Request-Id: <uuid> to every request — a new UUID per request, not per session — allows your backend team to search server logs by request ID and find the exact server-side logs for what happened during that specific request. When a user reports a bug, they share the request ID from the app's logs, and the backend team can trace the full path through microservices, databases, and external APIs. A X-Trace-Id header that persists across a logical user session (created once, stored in memory) enables correlation of all requests within a single user workflow, which is valuable for understanding complex bug reports.
"A good User-Agent is a free analytics dimension. When a bug is reported on version 2.0.3, I can filter server logs by that version to see exactly what requests those users were making. Also useful for gradual API migration: server returns v2 response format only for app versions >= 2.1.0."
48. Scenario: Your backend uses GraphQL. How do you integrate Apollo Kotlin into an Android app with caching and error handling?
Apollo Kotlin is the official GraphQL client for Android — it generates type-safe Kotlin code from your .graphql query files. It has built-in normalized caching and coroutine support.
// build.gradle.kts // plugins { id("com.apollographql.apollo3") version "3.8.0" } // implementation("com.apollographql.apollo3:apollo-runtime:3.8.0") // implementation("com.apollographql.apollo3:apollo-normalized-cache:3.8.0") // Step 1: Define query in src/main/graphql/GetUser.graphql // query GetUser($id: ID!) { // user(id: $id) { id name email avatar { url } } // } // Apollo generates: GetUserQuery + GetUserQuery.Data types // Step 2: Configure Apollo client with caching val store = SqlNormalizedCacheFactory(context, "apollo.db") val apolloClient = ApolloClient.Builder() .serverUrl ("https://api.example.com/graphql") .normalizedCache (store) // persistent normalized cache .addHttpHeader ("Authorization", "Bearer $token") .build () // Step 3: Execute query with cache policy suspend fun getUser(id: String): User? { val response = apolloClient .query (GetUserQuery(id)) .fetchPolicy (FetchPolicy.CacheFirst) // cache → network .execute () if (response.hasErrors()) { val error = response.errors?.first() throw GraphQLException(error?.message ?: "GraphQL error") } return response.data?.user?.toDomain () } // FetchPolicy options: // CacheFirst: serve cache, update in background // NetworkFirst: network, fall back to cache // CacheOnly: never hit network (offline mode) // NetworkOnly: always fresh (like Retrofit default) // CacheAndNetwork: emit cache immediately, then network (two emissions) // Mutation (writing data) val result = apolloClient.mutation (UpdateUserMutation(name = "Alice")).execute () // Apollo automatically updates the normalized cache after mutations
Apollo Android is the standard GraphQL client library for Android, generating Kotlin types from your .graphql query files at compile time. For a query file containing query GetUser($id: ID!) { user(id: $id) { name email avatar } }, Apollo generates a GetUserQuery class and a GetUserQuery.Data response type with exactly the fields you requested — no extra fields, no missing fields. Executing the query is type-safe: apolloClient.query(GetUserQuery(id = userId)).execute(). The compiler enforces that variables match the query schema and that all non-null schema fields are handled.
Apollo's normalized cache is one of its most powerful features. Instead of caching responses by query (as most REST caches do), it caches objects by their type and ID: each User with an ID is stored once. When any query returns that user, the cached record is updated. All queries that previously returned that user automatically see the update on next access — without any explicit cache invalidation. This means a mutation that updates a user's name propagates to every screen that displayed that user, without coordinating cache updates. Configure it with MemoryCacheFactory for speed or SqlNormalizedCacheFactory for persistence across process restarts.
The fetch policy controls the cache/network trade-off per query. FetchPolicy.CacheFirst returns cached data immediately (if available) and optionally refreshes from the network in the background — best for data that does not change frequently and where loading speed is prioritised. FetchPolicy.NetworkOnly always fetches from the network — correct for data that must always be current, like account balances. FetchPolicy.CacheAndNetwork emits the cached version first, then fetches from the network and emits again — the screen shows data instantly and silently refreshes, giving the fastest perceived load time while staying current. Error handling requires attention: GraphQL returns HTTP 200 even for errors. Check response.hasErrors() — errors live in response.errors alongside the data, and partial results are common.
"Apollo's normalized cache is its biggest advantage over raw REST+Room. When a mutation updates a user, Apollo automatically updates every query that includes that user — no manual cache invalidation code. The cache stores data by type+ID, not by query string."
49. What is the Retrofit CallAdapter? How do you create a custom one to wrap responses in Result<T>?
A CallAdapter transforms Retrofit's Call into another type — Flow, Result, or a custom wrapper. Instead of writing try-catch in every repository, a custom adapter wraps every response automatically.
// Without custom adapter: try-catch in every repository function suspend fun getUser(id: String): Result<User> { return try { Result.success (api.getUser (id)) } catch (e: Exception) { Result.failure (e) } } // Same boilerplate in every single repository function — repeated 50 times // With custom CallAdapter: automatic wrapping interface UserApi { @GET("users/{id}") suspend fun getUser(@Path("id") id: String): Result<User> // Returns Result<User> directly — no try-catch needed anywhere } // Custom CallAdapter factory class ResultCallAdapterFactory : CallAdapter.Factory() { override fun get(returnType: Type, annotations: Array<Annotation>, retrofit: Retrofit): CallAdapter<*, *>? { if (getRawType (returnType) != Call::class.java) return null val upperType =getParameterUpperBound (0, returnType as ParameterizedType) if (getRawType (upperType) != Result::class.java) return null val resultType =getParameterUpperBound (0, upperType as ParameterizedType) return ResultCallAdapter<Any>(resultType) } } // Register on Retrofit val retrofit = Retrofit.Builder() .addCallAdapterFactory (ResultCallAdapterFactory()) .build () // Now in repository — clean, no try-catch suspend fun getUser(id: String): Result<User> = api.getUser (id).map { it.toDomain () } // HttpException and IOException automatically become Result.failure()
Retrofit's CallAdapter mechanism transforms the internal Call<T> into any return type your interface declares. By default, Retrofit knows how to return Call<T> and (with the coroutine adapter) suspend functions returning T or Response<T>. A custom CallAdapter teaches Retrofit to understand new return types — most commonly Result<T>. When every interface method returns Result<T>, the adapter wraps successful responses in Result.success(value) and any exception (IOException, HttpException, SerializationException) in Result.failure(exception). Callers fold over the result — no try-catch needed anywhere.
The DRY benefit is significant. Without a custom adapter, every repository method wraps its call in try-catch: try { Result.success(api.getUser(id)) } catch (e: Exception) { Result.failure(e) }. With the adapter, the interface method returns suspend fun getUser(id: String): Result<UserDto> and the adapter applies the wrapping automatically. The repository calls api.getUser(id) and gets a Result<UserDto> with no try-catch — error handling is centralised in the adapter. This removes a class of bugs where a developer forgets to catch an exception type, and makes every repository method consistently typed.
Register the adapter with Retrofit.Builder().addCallAdapterFactory(ResultCallAdapterFactory()).build(). Retrofit tries each registered adapter in order and uses the first one that claims to handle the return type — ResultCallAdapterFactory claims return types that are Result<*>. The RxJava adapters (RxJava2CallAdapterFactory, RxJava3CallAdapterFactory) follow the same pattern but produce Observable, Single, and Completable return types for teams using RxJava. The call adapter pattern is also how Kotlin Flow-returning Retrofit methods are sometimes implemented, though the canonical approach is suspend functions wrapped in flow { emit(api.call()) } at the repository layer.
"A ResultCallAdapterFactory is one of the highest-ROI architecture patterns in Android networking. Write it once, and every suspend fun returning Result<T> in your API interfaces gets automatic error handling. Your repositories never need try-catch — they just call the API and map the result."
50. Scenario: Conduct a complete networking code review. What 10 things do you look for?
A systematic networking code review checklist prevents the most common production networking bugs — from memory leaks to security vulnerabilities. Each item maps to a real production issue.
// 1. ❌ Multiple OkHttpClient instances class UserRepo { val client = OkHttpClient() } // each creates a new pool class OrderRepo { val client = OkHttpClient() } // ❌ no pooling! // ✅ One @Singleton OkHttpClient shared via DI // 2. ❌ Gson instead of Kotlin Serialization // Can silently set non-null Kotlin fields to null → runtime NPE // 3. ❌ Missing ignoreUnknownKeys val json = Json { } // ❌ new API field = crash on old app versions val json = Json { ignoreUnknownKeys = true } // ✅ // 4. ❌ Auth token in query param @GET("users?api_key=secret123") // ❌ in logs, history, proxy // ✅ Authorization header via interceptor // 5. ❌ No error body parsing throw Exception("HTTP ${e.code()}") // ❌ ignores server error message // ✅ Parse e.response()?.errorBody()?.string() // 6. ❌ No timeout configured val client = OkHttpClient() // default timeout = 10 seconds — often wrong // ✅ Explicit connectTimeout, readTimeout, writeTimeout // 7. ❌ Token refresh without Mutex // Multiple 401s → multiple simultaneous refresh calls // ✅ Mutex in TokenRefreshInterceptor // 8. ❌ Network call in ViewModel constructor class HomeViewModel : ViewModel() { val user = runBlocking { repo.getUser () } // ❌ blocks during VM creation } // ✅ launch in init or call loadData() from UI // 9. ❌ HttpLoggingInterceptor.Level.BODY in release // Logs auth tokens and user data to Logcat in production // ✅ if (BuildConfig.DEBUG) addInterceptor(logging) // 10. ❌ No SSL pinning for sensitive endpoints // ✅ CertificatePinner for payment/auth endpoints at minimum
The single most impactful networking architecture decision is one @Singleton OkHttpClient shared across all Retrofit instances. The client owns the connection pool and the thread pool — sharing it means all API calls across the app benefit from connection reuse. Creating multiple clients means multiple pools that cannot share connections, and multiple thread pools that waste OS resources. Injected via Hilt as a singleton, the client is configured once with all interceptors and reused everywhere. This is non-negotiable: a new client per request is one of the most common and most expensive networking anti-patterns in Android.
Kotlin Serialization with ignoreUnknownKeys = true is the safe default for all API parsing. Null-safe parsing ensures Kotlin's type system is respected — non-nullable fields that receive null throw immediately rather than corrupting data silently. ignoreUnknownKeys ensures old app versions survive API evolution — the most important production resilience setting for any app that cannot force users to update. All auth material — bearer tokens, API keys, session identifiers — belongs in HTTP headers, never in query parameters or path segments. URLs appear in server access logs, CDN logs, proxy logs, and analytics systems. A token in a URL is a token in a log file accessible to anyone with log access.
Two patterns that protect correctness under concurrent load: parsing errorBody() for structured server errors rather than relying only on the HTTP status code, and using a Mutex in the token refresh interceptor. The error body contains the specific error type and message that allows the ViewModel to take the right action — show the right error message, redirect to the right screen, or retry with corrected input. Without parsing the error body, all 4xx errors look the same. The refresh mutex prevents the token stampede: ten simultaneous 401s produce one refresh call and nine token reuses. Without it, you risk making ten refresh calls, potentially invalidating sessions or exhausting refresh token quotas. Both patterns have been described in detail in earlier questions — implement them from the start rather than retrofitting them after encountering production incidents.
"In a networking code review I check these 10 items in order of severity: (1) OkHttpClient not singleton, (2) logging in release, (3) no ignoreUnknownKeys, (4) auth token in URL, (5) no timeouts, (6) error body ignored, (7) no Mutex in token refresh, (8) no SSL pinning for auth, (9) network on main thread, (10) missing Gson→KotlinX migration. Finding 3+ is common in codebases that grew without a networking specialist."