How do you handle flaky networks — retries, exponential backoff, and circuit breakers on a mobile client?
A retry loop without jitter or a ceiling doesn't recover from a flaky network — it turns one bad connection into a self-inflicted denial of service.
LEARN BY DOINGPut your intuition to work3 steps
How do you handle flaky networks — retries, exponential backoff, and circuit breakers on a mobile client?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Retrying is easy; retrying correctly is the interview question
Every mobile client eventually needs to retry a failed request — a cell tower handoff dropped the connection, a load balancer momentarily returned a 503, a timeout fired because the user walked into an elevator. The naive instinct is a while loop that calls the same endpoint again immediately. That instinct is wrong in three separate ways at once: it can retry a request that already succeeded server-side (duplicating a payment), it can hammer a server that's already struggling and make the outage worse across every client doing the same thing at the same moment, and it has no idea when to give up. A senior answer separates the problem into three independent decisions: is this error retryable at all, how long do I wait between attempts, and when do I stop trying entirely for this endpoint.
#Deciding what's retryable
Not every failure deserves another attempt. A connection timeout, a socket reset, or a 5xx response usually reflects a transient server or network condition and is worth retrying. A 4xx response — except 429, which explicitly means "you're being throttled, try again later, possibly after Retry-After" — almost always means the request itself is wrong (bad auth, malformed body, not found), and retrying it identically just wastes a retry budget on a request that will fail every time. The other half of "is this retryable" is idempotency: retrying a GET is free, but retrying a POST that creates a resource risks creating it twice if the first attempt actually succeeded and only the response was lost. The fix is an idempotency key generated once per logical operation and sent on every retry, so the server can recognize "I already processed this" and return the original result instead of doing the work again.
#Backoff with jitter, not backoff alone
Pure exponential backoff (wait 1s, 2s, 4s, 8s...) fixes the "stop hammering immediately" problem but not the "every client backs off in lockstep" problem — if a thousand devices all failed at the same moment because of a shared outage, they all retry at the same moment too, in synchronized waves that never let the server recover. Jitter — randomizing the actual delay within the exponential envelope — breaks that synchronization. "Full jitter" (pick a random delay between 0 and the exponential cap) is usually enough; more sophisticated variants track the previous delay to avoid clustering near zero, but full jitter alone gets you most of the benefit for a fraction of the complexity.
class RetryWithBackoffInterceptor(private val maxRetries: Int = 4,private val baseDelayMs: Long = 250,private val maxDelayMs: Long = 8_000,) : Interceptor {override fun intercept(chain: Interceptor.Chain): Response {var attempt = 0var lastException: IOException? = nullwhile (attempt <= maxRetries) {try {val response = chain.proceed(chain.request())if (response.isSuccessful || !isRetryable(response.code)) {return response}response.close()} catch (e: IOException) {if (!isRetryableException(e)) throw elastException = e}attempt++if (attempt > maxRetries) breakThread.sleep(backoffDelay(attempt))}throw lastException ?: IOException("Retries exhausted")}private fun isRetryable(code: Int) = code == 429 || code in 500..599private fun isRetryableException(e: IOException) =e is SocketTimeoutException || e is ConnectException// Full jitter: a random delay between 0 and the exponential cap.private fun backoffDelay(attempt: Int): Long {val exp = (baseDelayMs * 2.0.pow(attempt - 1)).toLong().coerceAtMost(maxDelayMs)return Random.nextLong(0, exp + 1)}}
Note the ceiling in two places: maxRetries bounds how many times we try at all, and maxDelayMs bounds how long a single wait can grow to, so backoff converges instead of eventually waiting minutes between attempts on a request the user is still staring at a spinner for.
#Circuit breakers: stopping before you even try
Retries help one request survive a blip. They don't help when an entire downstream dependency is down for an extended period — in that case, every request from every screen in the app keeps paying the full retry-and-timeout cost against something that has no chance of succeeding, burning battery, radio time, and perceived responsiveness for nothing. A circuit breaker tracks failure rate for a given dependency and, once it crosses a threshold, trips to an open state where calls fail immediately without even touching the network. After a cooldown window it moves to half-open and lets a single trial request through; success closes the circuit again, failure reopens it.
class CircuitBreaker(private val failureThreshold: Int = 5,private val resetTimeoutMs: Long = 30_000,) {private enum class State { CLOSED, OPEN, HALF_OPEN }private var state = State.CLOSEDprivate var consecutiveFailures = 0private var openedAt = 0L@Synchronizedfun <T> execute(call: () -> T): T {if (state == State.OPEN) {if (System.currentTimeMillis() - openedAt < resetTimeoutMs) {throw CircuitOpenException()}state = State.HALF_OPEN}return try {val result = call()onSuccess()result} catch (e: Exception) {onFailure()throw e}}@Synchronizedprivate fun onSuccess() {consecutiveFailures = 0state = State.CLOSED}@Synchronizedprivate fun onFailure() {consecutiveFailures++if (state == State.HALF_OPEN || consecutiveFailures >= failureThreshold) {state = State.OPENopenedAt = System.currentTimeMillis()}}}class CircuitOpenException : Exception("Circuit breaker is open — failing fast")
On mobile, the breaker is usually scoped per-host or per-feature rather than globally — you don't want a struggling analytics endpoint to trip a breaker that also guards checkout. Combine it with connectivity awareness (ConnectivityManager.NetworkCallback on Android, NWPathMonitor on iOS) so the client doesn't even attempt a call — retryable or not — while the device reports no network at all, and instead queues the request until connectivity returns.
#Putting it together at the UI layer
None of this is invisible to the user by accident — a senior implementation surfaces state, not just data: a screen bound to a circuit that's open should show "having trouble connecting" rather than an infinite spinner, and a queued offline write should show as pending rather than silently vanishing. The retry/backoff/circuit-breaker stack is what keeps that state honest instead of the UI lying about whether a request is in flight, retrying, or has actually given up.