AI-powered crash analysis is now available on all plans — including Free.Read the crash analysis guide

Mobile HTTP Client Crash Debugging: URLSession & OkHttp

NFNourin Mahfuj Finick··8 min read

Most mobile apps live or die by their networking layer. When an HTTP client crashes, the symptom almost never points at the code that failed: a URLSession task in flight deallocates its delegate, an OkHttp interceptor throws on a background thread, or a Retrofit converter chokes on a malformed JSON payload — and your users just see the app disappear. HTTP client crash debugging across URLSession, OkHttp, Retrofit, and Volley is a distinct discipline from ordinary crash triage because the failures are asynchronous, threaded, and frequently silent until the moment they are not. This guide walks through the crash modes that matter on both iOS and Android, with code you can lift directly into your app, and shows how to instrument the networking stack so the next crash arrives with context instead of a mystery.

Before diving in, it is worth naming why this is hard. A networking crash is rarely a clean, reproducible segfault. It is a race between a request lifecycle and an object lifecycle, or a type contract broken at the boundary between your app and the wire. The same root cause can surface as an EXC_BAD_ACCESS on iOS and a NullPointerException on Android, and the stack trace you get often terminates inside a framework you did not write. Treating the HTTP client as a first-class crash surface — not just a plumbing detail — is the first step toward stable networking.

URLSession on iOS: the delegate lifecycle trap

Apple's URLSession API looks deceptively simple until you realize that the URLSession object does not retain its delegate. When you create a session with a delegate, the session holds a weak reference to it. If nothing else keeps that delegate alive, it gets deallocated while a request is still in flight, and the next callback arrives at a dangling pointer — the classic cause of a URLSession-related EXC_BAD_ACCESS crash.

final class APIClient {
    // BUG: the session's delegate is weak; if only this session
    // references the delegate, it deallocates mid-request.
    func fetch() {
        let delegate = MySessionDelegate()
        let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
        let task = session.dataTask(with: URL(string: "https://api.example.com/user")!)
        task.resume()
        // delegate goes out of scope here -> crash on callback
    }
}

The fix is to hold a strong reference to the delegate (and the session) for as long as the session is alive, typically in a singleton or a class property. A second, subtler trap is mixing completion-handler and delegate-based APIs on the same session: a session configured with a delegate will ignore completion handlers, and vice versa, producing tasks that silently never call back. Apple's documentation on URLSessionDelegate calls out that invalidateAndCancel() tears down in-flight tasks — calling it from within a delegate callback, or while another subsystem still holds the session, is a reliable way to generate "task canceled" noise that your crash reporter will misattribute.

Background sessions deserve special care. URLSessionConfiguration.background(withIdentifier:) runs transfers in a separate process, so the delegate must be re-established at app launch via session(withIdentifier:) or the completion handler never fires — the app appears to hang, then gets watchdog-killed. Always route background upload/download work through a single, app-lifetime session object.

OkHttp: interceptors, dispatchers, and callbacks

On Android, OkHttp is the default HTTP engine under nearly every popular stack. Square's OkHttp documentation is explicit that interceptors run in the order they are added, and that an exception thrown from an application interceptor aborts the entire call chain. A common crash is an interceptor that assumes a request body or a header it did not create.

val client = OkHttpClient.Builder()
    .addInterceptor { chain ->
        val request = chain.request()
        // BUG: request.tag() is null for requests that never set a tag.
        val auth = request.tag(AuthTag::class.java)!!
        request.newBuilder().header("Authorization", auth.token).build()
            .let(chain::proceed)
    }
    .build()

Force-unwrapping a tag, header, or body that another code path never sets throws a NullPointerException on a background thread, and because OkHttp dispatches callbacks off the main thread by default, that crash arrives without a useful UI context. The OkHttp recipes page recommends defensive null checks and returning the original request rather than throwing.

Two more OkHttp-specific failure modes account for a large share of field reports. First, Callback.onResponse is invoked on a background thread; touching any view or LiveData there throws an Android framework exception that gets attributed to your networking code. Always marshal results back to the main thread explicitly. Second, the Dispatcher has a default limit of 64 concurrent requests and 5 per host; saturating it with blocking calls starves every other request and eventually triggers timeout cascades. For long-lived connections, either size the dispatcher to your workload or move to a connection pool you manage yourself.

client.newCall(request).enqueue(object : Callback {
    override fun onFailure(call: Call, e: IOException) {
        // Network-level failure; e is non-null, response is null.
    }
    override fun onResponse(call: Call, response: Response) {
        response.use {
            // Runs on a background thread — post UI updates to main.
            val body = it.body?.string() ?: return
            runOnUiThread { render(body) }
        }
    }
})

Retrofit: converter and thread crashes

Retrofit sits on top of OkHttp and translates HTTP into typed calls, which makes it the single most common source of "worked yesterday, crashes today" bugs. The highest-frequency culprit is the converter. Gson, Moshi, and kotlinx.serialization each behave differently when a JSON field is missing, has the wrong type, or is null where a non-null Kotlin type is declared. A Retrofit interface like this is a crash waiting to happen:

interface Api {
    @GET("users/{id}")
    suspend fun getUser(@Path("id") id: String): User
}
 
// BUG: if the server omits "createdAt", Gson leaves it null and
// Kotlin throws NullPointerException the moment the field is read.
data class User(val id: String, val createdAt: String)

Retrofit's converter documentation and the Moshi and kotlinx.serialization guides all make the same recommendation: model the contract defensively, with nullable fields and explicit defaults, and treat a decode failure as an error path rather than a fatal crash. Wrap responses in Retrofit's Response<T> type so a 4xx/5xx or a parse error surfaces as an object you can inspect, instead of throwing into your coroutine and killing whatever scope was waiting on it.

The second Retrofit crash is thread-related and easy to trigger: calling Call.execute() directly from the main thread throws NetworkOnMainThreadException, which Android enforces through its StrictMode policies. The symptom looks like a networking bug but is really a threading contract violation. Prefer enqueue or suspend functions backed by Retrofit's own dispatcher.

// Prefer this: Retrofit suspends on its own dispatcher.
suspend fun load() = try {
    api.getUser("42")
} catch (e: HttpException) {
    // HTTP error status; inspect e.code() and e.response().
    null
}

Volley and Alamofire: the legacy and the wrapper

Google's Volley is still widely deployed in older Android apps. Its most common crash is the same delegate-style problem as URLSession: a request completes after the Activity or RequestQueue has been torn down, and a response listener touches a dead view. Cancel requests in onStop, and never leak the queue into a static context. On iOS, Alamofire wraps URLSession and inherits every delegate-lifetime and background-session caveat described above, adding its own layer of response serializers that can throw when a response body is missing. Treat Alamofire crashes with the same root-cause lens you apply to raw URLSession.

Cross-cutting failure modes: timeouts, redirects, TLS, and cancellation

Some crash signatures are not client-specific at all. A request that never resolves because of a missing timeout eventually exhausts a thread pool or triggers an OS watchdog kill — configure explicit connect, read, and write timeouts on every client. A redirect loop (two endpoints 302ing to each other) surfaces as a deep stack of "too many redirects" and can look like an infinite-recursion crash; cap redirects with a custom interceptor or URLSession redirect policy. TLS handshake failures are frequently mistaken for code crashes when certificate pinning fails silently — see our guide on mobile certificate pinning and SSL failures for the full treatment. Finally, cancellation is the least-tested path: canceling a request mid-flight and then touching the response is a reliable source of "already canceled" exceptions. Make cancellation idempotent and always guard against using a canceled call's result.

These failure modes compound when a device moves between networks, which is why network transition crashes deserve their own handling. The point for this guide is simpler: every one of these conditions should be caught, logged, and attributed to the specific request that triggered it.

Instrumenting your HTTP client for crash reporting

A crash report that only says "EXC_BAD_ACCESS in URLSession" tells you almost nothing. The fix is to instrument the networking stack so every failure carries its request context — URL, method, status code, elapsed time, and the client that issued it. Wrap your interceptors and delegates to attach this metadata as breadcrumbs before the crash fires, and route both handled errors (non-2xx responses) and unhandled exceptions into the same pipeline so you can correlate a spike in 500s with a crash in the JSON converter a second later.

That correlation is exactly the gap a purpose-built mobile observability tool fills. Bugspulse captures crashes with the request and response context intact, so instead of guessing which endpoint or payload triggered a converter NullPointerException, you see it in the report. If you are still relying on a generic crash reporter that drops networking metadata on the floor, take a look at how Bugspulse handles mobile crash reporting and instrument your first HTTP client today.

Ready to stop chasing anonymous networking crashes? Create a free Bugspulse account and ship your next build with the HTTP client layer fully instrumented.