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:
- Save the current execution state (locals, next instruction)
- Return control to the caller
- 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:
suspendenables 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
| Aspect | Blocking | Suspending |
|---|---|---|
| Thread usage | Occupied | Released |
| Call stack | Preserved | Unwound |
| Scalability | Poor | Excellent |
| Cancellation | Manual | Built-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:
- Adds a hidden
Continuationparameter - Lifts local variables into fields
- Generates a state (
label) integer - 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.
