Why Your Network Calls on the Main Thread Aren’t Crashing your Android App
Understanding Why Your App Does Not Crash Despite Running Network Requests on the Main Thread and Why Following Best Practices is Essential
Introduction
As an Android developer, you have probably heard many times that network calls should never be executed on the main thread. However, you may have noticed that your app does not crash even when network logic runs on the main thread without explicitly switching to Dispatchers.IO
. What is happening? Has something changed? Let’s explore the details.
1. The Traditional Rule: Never Block the Main Thread
For years, Android developers have followed this rule: network operations should not run on the main thread because:
- The main thread is responsible for rendering the UI and handling user interactions.
- If a network request takes too long, it will freeze the UI and lead to an Application Not Responding (ANR) error.
- In Android versions before 4.0, executing a network call on the main thread would immediately crash the app with a
NetworkOnMainThreadException
.
If your app is not crashing, there must be a reason!
2. Why Is Your App Not Crashing?
2.1 OkHttp, Retrofit, and Ktor Do Not Always Handle Threading Automatically
If you are using Retrofit with OkHttp and suspending functions, OkHttp internally handles network requests on a background thread. However, this only applies when Retrofit uses coroutines or its default async execution model.
If you make a synchronous network request using OkHttp’s .execute()
method directly on the main thread, it will block and may cause an ANR.
Example:
val response = api.getData().execute() // This will block the main thread
However, if you use OkHttp’s asynchronous .enqueue()
method, it will run on an internal OkHttp-managed thread:
api.getData().enqueue(object : Callback<Response<MyData>> {
override fun onResponse(call: Call<Response<MyData>>, response: Response<MyData>) {
// Handle response
}
override fun onFailure(call: Call<Response<MyData>>, t: Throwable) {
// Handle failure
}
})
Similarly, if you are using Retrofit with suspend functions, it ensures that network requests are automatically moved off the main thread:
interface ApiService {
@GET("endpoint")
suspend fun getData(): Response<MyData>
}
Calling this inside a coroutine will suspend execution without blocking the main thread:
lifecycleScope.launch {
val result = api.getData() // Suspends but does not block the UI thread
handleResult(result)
}
Since coroutines suspend execution rather than blocking, the main thread remains responsive, and the app does not crash.
Similarly, if you are using Ktor Client, it also provides automatic threading management, but its behavior depends on the chosen engine
:
val client = HttpClient(CIO) // Uses non-blocking I/O
val response: HttpResponse = client.get("https://api.example.com/data")
Using Ktor with CIO
or OkHttp
engines ensures that requests are executed off the main thread by default. However, if you use blocking calls such as runBlocking
, you may inadvertently block the main thread and crash your app.
2.2 How Ktor Can Crash Your App???
While Ktor is designed to be non-blocking, improper usage can still lead to crashes. A common mistake is using runBlocking
on the main thread while performing network operations. This forces the coroutine to block execution instead of suspending it properly.
Example:
fun fetchData() {
runBlocking {
val response: HttpResponse = client.get("https://api.example.com/data")
println(response.bodyAsText())
}
}
If fetchData()
is called from the main thread, it blocks the UI, leading to potential ANRs and app freezes. Instead, the correct approach is to use launch
or async
inside a coroutine scope:
fun fetchData() {
CoroutineScope(Dispatchers.IO).launch {
val response: HttpResponse = client.get("https://api.example.com/data")
println(response.bodyAsText())
}
}
This ensures that the network request runs on a background thread and does not interfere with the main thread.
3. The Right Way: Always Use Dispatchers.IO
Even if your app does not crash, relying on implicit behavior is risky. The safest approach is to explicitly switch to Dispatchers.IO
when making network requests:
val result = withContext(Dispatchers.IO) {
api.getData()
}
This ensures that:
- The request runs on an optimized background thread.
- The main thread remains responsive.
- ANR issues on slow networks are avoided.
4. Key Takeaways
- Synchronous OkHttp calls (
.execute()
) on the main thread will block and may cause ANRs. - Asynchronous OkHttp calls (
.enqueue()
) are handled on a background thread automatically. - Retrofit with suspend functions ensures execution happens off the main thread.
- Ktor with appropriate engines like
CIO
ensures network operations are non-blocking. - Improper use of
runBlocking
in Ktor can still block the main thread and crash your app. - Coroutines suspend execution rather than blocking, keeping the UI thread responsive.
- Android 4.0 and later do not immediately throw a
NetworkOnMainThreadException
, but an ANR error can still occur if a network request takes too long. - Relying on implicit behavior is risky, and best practice is to always use
Dispatchers.IO
explicitly.
Even if your app appears to function correctly without Dispatchers.IO
, it is not guaranteed to behave predictably in all situations. The best approach is to ensure that all networking operations run on an I/O-optimized thread.
Have you observed this behavior in your projects? Share your thoughts in the comments below.
Dobri Kostadinov
Android Consultant | Trainer
Email me | Follow me on LinkedIn | Follow me on Medium | Buy me a coffee