Yunus Emre Alpak

Under the await Line: How Swift Concurrency Really Works

2026 · 06 · 018 min read

Writing an await line takes a second. Understanding the machinery beneath it fundamentally changes how you think about concurrent code. State machines, hidden frames on the heap, and the subtle-but-decisive difference between 'suspending' and 'blocking' — it's all here.

async = state machine — what happens at an await line

An innocent line

let user = await loadUser() — it looks like an ordinary function call. But await isn't just a keyword; it's a doorway. Behind that door the compiler split your function into pieces, moved its state to a completely different region of RAM, and got ready to hand off the thread you were running on to someone else. This post opens up that invisible machinery — the state-machine transformation, the async frame on the heap, the difference between suspend and block — from start to finish. The examples are in Swift, but the mechanism is conceptually the same in C#, Rust, and Kotlin.

Why we can't just block the thread while waiting

Think of a thread (a line of execution that runs code one step at a time) as a "one-handed worker." It's expensive: each one holds megabytes of memory, switching between them has a cost, and the number that can truly run in parallel is capped by your CPU cores.

Mobile and server code spends most of its time waiting: network, disk, database. The classic approach is to block the thread while waiting — stop it and wait for the work to finish. The problem is that a blocked thread still counts as busy: it can't do anything else, it just sits there idle. If you block one thread per pending task, thousands of concurrent requests turn into thousands of dead threads; memory balloons and the scheduler chokes. The whole point of async/await is this: free the thread while it waits.

The big idea: async is a state machine

When you make a function async, the compiler doesn't compile it like an ordinary function. It turns it into a state machine — a structure with a finite set of states that moves from one to the next — split at every await.

func loadUser() async -> User {
    let data = await fetchData()   // stop 1
    let user = await parse(data)   // stop 2
    return user
}

It breaks into three pieces: from the start up to fetchData (S0), after fetchData returns up to parse (S1), and after parse returns up to return (S2). Each await is a suspension point: the place where the function can release the thread and later continue from where it left off. As a mental model, picture this:

switch state {
  case 0: prepare data; state = 1; if suspend { return }   // release the thread
  case 1: user = parse;  state = 2; if suspend { return }
  case 2: return user
}

Here return doesn't mean "I'm done"; it means "I'm suspended for now, call me back with resume." C# and Rust compile async to exactly this switch; Swift achieves the same behavior by physically splitting the function into separate "continuation functions" (LLVM coroutine splitting).

Where does the state live? The stack's two rules

An ordinary function's local variables live in a frame on the stack (the fast, automatic workspace for calls) and vanish when the function returns. This works because a synchronous function runs on a single thread, uninterrupted.

But the stack has two iron rules: (1) that stack belongs to that thread, and (2) when a frame "returns" or the thread moves on to other work, its space is wiped and overwritten. At an await, an async function breaks exactly these rules: it releases the thread and may resume on a different thread. So state that crosses a suspension can't live on the stack.

The solution is to store that state in an async frame that lives on the heap (long-lived memory). Don't think "the heap is expensive": this isn't a regular malloc; each task has its own allocator that works with stack discipline (LIFO) and is cheap — almost as light as bumping a pointer. Few threads + cheap frames = tens of thousands of concurrent pending tasks.

The finest detail: which locals survive?

Here's the part most people don't know. The async frame doesn't save all of its locals — only the ones that cross an await, i.e., the ones still needed after suspension.

func loadProfile(id: Int) async -> Profile {
    let token  = makeToken(id)                 // synchronous
    let user   = await fetchUser(id, token)    // await #1
    let avatar = await fetchAvatar(user.url)   // await #2
    return Profile(user: user, avatar: avatar)
}
variablecrosses which await?saved in the frame?
id, tokennone (consumed on line 2)no
userawait #2 (needed in return)yes
avatarnone (no suspension afterward)no

So at await #1 the frame holds none of your variables; at await #2 it holds only user. The rule is clean: if a local crosses an await, it goes into the frame; if not, it doesn't. Alongside this, the frame carries a few "bookkeeping" fields at every suspension: the continuation that says where to resume (resume), a pointer to the caller that awaited you (caller link), the context to return to (executor), and a slot for the awaited result (result slot).

Suspend ≠ Block

This distinction is the heart of the whole topic:

await = lend out the thread, take it back when needed — not blocking, but sharing.

The infrastructure that makes this possible is a fixed-size cooperative thread pool (roughly one thread per core). It has a single contract: never block these threads. Thread.sleep, synchronous blocking I/O, or waiting on a lock break that contract and jam the pool; await, Task.sleep, and async I/O obey it by releasing the thread.

A free gift: stack traces

This design has a nice side effect. In old callback (closure) based code, by the time the callback runs with the result, the call chain that started it has long been wiped from the stack; so at the moment of an error you can't see "who called me" — the stack trace is broken. (This was one of the reasons async/await was added to the language.)

In an async frame, by contrast, every frame is linked to its caller's frame through the caller link. This chain is a copy of the logical call stack, living on the heap. Even when the thread's stack empties at an await boundary, the runtime can walk this chain to produce a proper async stack trace. So the answer to "where do we put the state" (the async frame chain) is at the same time the answer to "how do we reconstruct a clean backtrace."

Two more subtleties: resume happens on the correct executor — which is why in a @MainActor function you're guaranteed to return to the main thread after an await. And because actors are reentrant, while you're suspended another call can slip in on the same actor; an assumption you validated before the await may no longer hold afterward — re-check critical state after an await.

Mental model

To fit it in one sentence: async is a function the compiler turns into a pause-and-resume machine split at every await; its state lives not on the stack but in a cheap async frame on the heap; await doesn't block the thread, it frees it; and thanks to that freeing, a single core can carry thousands of pending tasks. The next time you write await, you'll be seeing the entire machine spinning beneath that line.