Suspending Functions — How They Work

If you’ve been working with Kotlin long enough—especially on Android or backend services—you’ve almost certainly written suspend fun.
Yet many experienced developers still describe suspend functions as “functions that run asynchronously” or “functions that don’t block threads.”

Those statements are partially correct, but dangerously incomplete.

To use coroutines correctly—and to avoid subtle performance and cancellation bugs—you must understand what suspend functions actually are, how they are implemented by the compiler, and where their limits are.

This article explains suspend functions from the ground up, focusing on mechanics rather than syntax.


Key Characteristics of Suspend Functions

At a surface level, a suspend function:

  • Can pause execution without blocking a thread
  • Can resume later from the same logical point
  • Can only be called from another suspend function or a coroutine

But the more important characteristics lie deeper.

Suspension Is Not Automatic

A suspend function does not pause itself magically.

The JVM has no concept of “pausing a function and resuming it later.” Suspension is achieved entirely through compiler transformations, and it can only happen at well-defined suspension points.

This single fact explains most coroutine misunderstandings.


Suspension Points: Where Pausing Is Actually Possible

Suspension happens only when a suspend function calls another suspend function that may suspend, such as:

  • delay()
  • await() (e.g., Deferred.await)
  • withContext()
  • suspendCoroutine { }
  • suspendCancellableCoroutine { }

At these points, the Kotlin compiler has generated code that can:

  1. Save the current execution state (locals, next instruction)
  2. Return control to the caller
  3. Resume later via a Continuation

If no suspension point is reached, the function never suspends.


Code Between Suspension Points Runs Normally

Inside a suspend function, any code between suspension points runs synchronously on the current thread, just like regular Kotlin code.

Example:

suspend fun example() {
    println("Start")

    // This code runs synchronously and will not pause
    for (i in 1..1_000_000) {
        doCpuWork()
    }

    delay(1000) // suspension point

    println("End")
}

Even though this is a suspend function:

  • The loop does not yield
  • The thread is fully occupied until delay() is reached

This means:

suspend enables pausing, but does not guarantee it.


Why Suspend Functions Exist

Before coroutines, asynchronous programming relied on:

  • Callbacks
  • Futures
  • Reactive streams

These approaches suffered from:

  • Inverted control flow
  • Broken stack traces
  • Complex error handling
  • Difficult cancellation

Suspend functions solve these problems by enabling:

  • Sequential-looking async code
  • Structured concurrency
  • Automatic cancellation propagation
  • Clear error semantics

But their real power becomes clear when you compare blocking and suspending.


How Suspending Differs from Blocking


Blocking

Blocking means:

  • A thread is occupied
  • The call stack remains intact
  • The thread cannot do other work

Example: Blocking

fun fetchUserBlocking(): User {
    Thread.sleep(1000) // blocks the thread
    return User("Alice")
}

This is dangerous on:

  • Android Main thread → ANR
  • Limited thread pools → starvation

Suspending

Suspending means:

  • The thread is released
  • The call stack is unwound
  • Execution state is stored in a continuation

Example: Suspending

suspend fun fetchUser(): User {
    delay(1000) // suspends without blocking
    return User("Alice")
}

No thread is blocked. The coroutine is simply paused.


Side-by-Side Comparison

AspectBlockingSuspending
Thread usageOccupiedReleased
Call stackPreservedUnwound
ScalabilityPoorExcellent
CancellationManualBuilt-in
UI safety

Call Stack & Continuation

A key mental shift is realizing that suspend functions do not preserve the call stack.

When suspension happens:

  • The stack is discarded
  • Execution state is captured in a Continuation

What Is a Continuation?

A Continuation<T> represents:

  • “Everything needed to resume execution later”

Simplified:

interface Continuation<T> {
    val context: CoroutineContext
    fun resume(value: T)
    fun resumeWithException(e: Throwable)
}

It holds:

  • The next instruction to execute
  • Local variables
  • Coroutine context

How Kotlin Implements Suspend Functions Internally

At the bytecode level, every suspend function is transformed into:

fun foo(continuation: Continuation<T>): Any?

Why Any??

  • It may return a value immediately
  • Or return the marker COROUTINE_SUSPENDED

Why a State Machine?

Because a suspend function can suspend multiple times.

Example:

suspend fun example() {
    println("A")
    delay(100)
    println("B")
    delay(100)
    println("C")
}

The compiler turns this into a state machine:

when (state) {
    0 -> { println("A"); state = 1; suspend }
    1 -> { println("B"); state = 2; suspend }
    2 -> { println("C"); return }
}

Each suspension point becomes a state.


Compiler Transformations Summary

The compiler:

  1. Adds a hidden Continuation parameter
  2. Lifts local variables into fields
  3. Generates a state (label) integer
  4. Converts suspend calls into resumable states

This explains:

  • Strange stack traces
  • Why locals survive suspension
  • Why coroutines are cheap

Visual Mental Model

Think of a suspend function as:

A resumable computation whose state lives in an object, not on the stack

  • Stack → temporary
  • Continuation → persistent

Creating Custom Suspend Functions

Sometimes you need to wrap callback-based APIs.


suspendCoroutine

suspend fun fetchUser(): User =
    suspendCoroutine { continuation ->
        api.fetchUserAsync { result ->
            continuation.resume(result)
        }
    }

Explanation

  • You manually control resumption
  • No cancellation support
  • Easy to misuse

suspendCancellableCoroutine

This should be your default choice.

Why Use It?

  • Supports coroutine cancellation
  • Prevents leaks
  • Integrates with structured concurrency

Example: Wrapping a Callback API

Given:

fun fetchUser(callback: (Result<User>) -> Unit)


Step 1: Create the Suspend Wrapper

suspend fun fetchUserSuspend(): User =
    suspendCancellableCoroutine { continuation ->

        fetchUser { result ->
            result
                .onSuccess { continuation.resume(it) }
                .onFailure { continuation.resumeWithException(it) }
        }

        continuation.invokeOnCancellation {
            // Cancel request if possible
        }
    }


Step 2: Use It Inside a Coroutine

viewModelScope.launch {
    try {
        val user = fetchUserSuspend()
        showUser(user)
    } catch (e: Exception) {
        showError(e)
    }
}


Explaining the Tricky Parts


Why suspendCancellableCoroutine?

Because cancellation is fundamental to coroutines.
If a coroutine is cancelled, the underlying work must stop.

Failing to handle this causes:

  • Resource leaks
  • Wasted network calls
  • Hard-to-debug crashes

Why Call resume() Inside Callbacks?

Because:

  • The coroutine is paused
  • Nothing continues until you explicitly resume it
  • resume() tells the state machine to continue execution

Why Cancellation Handling Matters

Cancellation is:

  • Cooperative
  • Asynchronous
  • A core coroutine guarantee

A cancelled coroutine must:

  • Stop work
  • Not resume normally
  • Clean up resources

suspendCancellableCoroutine enforces this contract.


Final Thoughts

Suspend functions are not:

  • Threads
  • Magic
  • Just syntactic sugar

They are:

  • Compiler-generated state machines
  • Powered by continuations
  • Designed for structured concurrency

Understanding this turns coroutines from “nice syntax” into a reliable mental model—and that’s the difference between code that works in demos and code that survives production.

Leave a Reply

Your email address will not be published. Required fields are marked *