← Back to writing

What Actually Happens When a Suspend Function Makes a Network Call?

If the thread is released while a request is in flight, what is actually waiting for the response? Coroutines, threads, sockets, and continuations, separated.

  • Kotlin
  • Coroutines
  • Android
  • Networking

The question

In modern Android development, we write asynchronous network requests that look completely synchronous:

viewModelScope.launch {
    val user = apiService.fetchUser(userId) // suspending call
    updateUi(user)
}

The standard explanation goes:

“The function suspends without blocking the thread. The thread is released back to the thread pool and can do other work while the network request is in flight.”

This sounds intuitive, but pause and think about it for a moment:

If the thread is released, what is actually waiting for the response from the server?

Does a hidden thread stay blocked? Does the CPU poll a socket in a loop? If the coroutine is “suspended,” where does it live while bytes travel across the internet?

My initial mental model

When I first learned coroutines, my mental model looked like this:

Coroutine pauses ──► Thread goes to sleep ──► Server responds ──► Thread wakes up

This model conflates suspension with thread sleep. If a thread is sleeping, it cannot do other work. If 50 coroutines make network requests simultaneously, you would need 50 threads. But coroutines easily handle 10,000 concurrent network calls on a single thread pool.

So who is actually doing the waiting?

What actually happens: Unpacking the Layers

To understand where the waiting happens, we must separate six distinct concepts that often get blurred together:

[Coroutine]               - A state machine that can pause and resume
    │
    ▼
[Continuation]            - An object holding the rest of the computation + local variables
    │
    ▼
[HTTP Client (OkHttp)]    - A library managing connection pools and request queues
    │
    ▼
[OS Socket]               - A Linux file descriptor managed by the Linux kernel
    │
    ▼
[Kernel Event Loop]       - Linux epoll waiting for hardware network interface interrupts
    │
    ▼
[Physical Wire / Wi-Fi]   - Packets traveling over radio frequencies

The key realization: No application thread is waiting. The waiting is handled entirely by the operating system kernel and hardware interrupt controllers.

Step 1: The Suspend Function Transforms into a State Machine

When the Kotlin compiler encounters a suspend function, it rewrites the bytecode using Continuation-Passing Style (CPS). Every suspend function receives an extra hidden parameter: Continuation<T>.

When apiService.fetchUser(userId) is invoked, it returns a sentinel value: COROUTINE_SUSPENDED.

Your function immediately returns. The calling worker thread is not blocked; it finishes its current run loop and picks up the next task from the coroutine scheduler.

Step 2: Retrofit Bridges to OkHttp via suspendCancellableCoroutine

How does Retrofit bridge OkHttp’s asynchronous callbacks to Kotlin’s suspension mechanism?

Under the hood in Retrofit’s Kotlin extensions, it uses suspendCancellableCoroutine:

// Simplified from Retrofit's KotlinExtensions.kt
suspend fun <T : Any> Call<T>.await(): T {
    return suspendCancellableCoroutine { continuation ->
        // 1. Hook up cancellation
        continuation.invokeOnCancellation {
            cancel()
        }

        // 2. Enqueue the OkHttp asynchronous call
        enqueue(object : Callback<T> {
            override fun onResponse(call: Call<T>, response: Response<T>) {
                if (response.isSuccessful) {
                    continuation.resume(response.body()!!)
                } else {
                    continuation.resumeWithException(HttpException(response))
                }
            }

            override fun onFailure(call: Call<T>, t: Throwable) {
                continuation.resumeWithException(t)
            }
        })
    }
}

Notice what happens here:

  • continuation is captured as an object reference in the heap.
  • OkHttp’s enqueue() sends the request onto OkHttp’s internal multiplexer queue.
  • The suspendCancellableCoroutine block exits, leaving the continuation parked in memory.

Step 3: What the Kernel Does (The True “Waiting”)

OkHttp writes the HTTP request headers and body to an operating system socket (a Linux file descriptor).

Once bytes leave the socket buffer, OkHttp’s I/O worker thread does not sit in a while(true) loop. Instead, the Linux kernel registers the socket file descriptor with an epoll interest list.

While packets travel across the ocean to your API server:

  • Zero CPU cycles are consumed.
  • Zero threads are blocked.
  • The network interface card (NIC) listens for incoming radio packets.

When the server finally responds:

  1. The NIC receives ethernet frames and triggers a hardware interrupt.
  2. The Linux kernel network stack processes TCP packets and places the payload into the socket’s read buffer.
  3. The kernel signals the epoll listener, waking up OkHttp’s selector thread.
  4. OkHttp reads the bytes, parses the HTTP response, and invokes the Callback.onResponse() callback.

Step 4: Resuming the Continuation

Inside onResponse(), OkHttp invokes:

continuation.resume(response.body())

What does resume() do? It dispatches the continuation to the coroutine’s designated CoroutineDispatcher (for instance, Dispatchers.Main or Dispatchers.Default).

The dispatcher puts the continuation into its work queue. When a worker thread picks it up, it calls:

// State machine continues from the saved label:
continuation.resumeWith(Result.success(user))

Execution resumes at line 3 of your original code, seamlessly updating the UI!

The surprising part: The Heap Replaces the Stack

In traditional synchronous programming, state is stored on the thread’s call stack:

[Stack Frame: fetchUser]
[Stack Frame: onClick]
------------------------
(Thread is pinned until the stack unwinds)

In coroutines, suspension copies local variables onto the heap inside the Continuation object.

Because your local variables are safe on the heap, the thread stack can completely unwind. The thread is freed to render 60fps animations or handle touch gestures while the network request is floating across the Pacific.

Practical consequences for Android developers

1. Cooperative Cancellation is Automatic

Because Retrofit registers continuation.invokeOnCancellation { cancel() }, if the user navigates away from your screen and viewModelScope cancels, the in-flight OkHttp call is cancelled immediately. Socket buffers are closed, and no wasted bytes are downloaded over the cellular radio.

2. High Concurrency without Thread Costs

On Android, each JVM thread consumes ~1MB of stack memory plus kernel scheduling overhead. Creating 1,000 threads will crash the app with an OutOfMemoryError.

With suspending network calls, 1,000 concurrent requests consume only a few kilobytes of heap memory for the 1,000 parked Continuation objects. The OS kernel handles the socket multiplexing effortlessly.

What I would remember

  1. Threads don’t wait; the kernel does: While a request is in flight, neither your coroutine nor OkHttp is burning CPU cycles. The Linux kernel and NIC hardware wait for packets via epoll.
  2. Continuations are callbacks in disguise: A suspend function is compiled into a state machine that resumes via an object callback on the heap.
  3. Suspension is just a clean stack unwind: The thread exits the function naturally and returns to its dispatcher queue.

Further reading

  • Roman Elizarov: The Reasoned Explanation of Coroutines
  • Android AOSP: libcore/ojluni/src/main/native/EPoll.c
  • Jake Wharton: Retrofit Coroutine Call Adapter implementation