Journey before destination, tests before production

Deep Dive: How Async/Await Works Under the Hood in Swift

January 17, 2026

When you write async/await code, it feels magical—asynchronous operations look like synchronous code, yet your program doesn’t block. But what’s actually happening under the hood? How does Swift transform your sequential-looking code into non-blocking, resumable operations?

In this deep dive, we’ll explore the internal mechanisms that make async/await work in Swift. We’ll examine state machines, continuations, async frames, and executor management to understand how the language achieves efficient asynchronous programming.

The Core Problem

Before async/await, asynchronous programming meant callbacks, manual state tracking, and complex control flow:

// The old way - callback hell
func fetchUserData(userId: Int, completion: @escaping (User) -> Void) {
    fetchFromDatabase(userId: userId) { userData in
        fetchUserPreferences(userId: userId) { preferences in
            fetchUserPosts(userId: userId) { posts in
                let user = User(userData: userData, preferences: preferences, posts: posts)
                completion(user)
            }
        }
    }
}

With async/await, this becomes:

// The modern way
func fetchUserData(userId: Int) async -> User {
    let userData = await fetchFromDatabase(userId: userId)
    let preferences = await fetchUserPreferences(userId: userId)
    let posts = await fetchUserPosts(userId: userId)
    return User(userData: userData, preferences: preferences, posts: posts)
}

The challenge: how do we make this sequential-looking code actually suspend, free up the thread, and resume later without blocking?

The State Machine Transformation

Swift solves this problem by transforming your async function into a state machine at compile time. Each await point becomes a distinct state.

Swift’s Async Frame Approach

When you write an async function, Swift allocates an async frame that persists across suspension points.

Here’s what you write:

func getData() async -> Int {
    let result = await fetch()
    let processed = process(result)
    return processed
}

Swift allocates an async frame that conceptually looks like:

┌────────────────────────┐
│   Async Frame          │
├────────────────────────┤
│ Parent frame pointer   │
│ Resume function ptr    │
│ Resume point (state)   │
│ Executor reference     │
│ Local: result          │
│ Local: processed       │
└────────────────────────┘

Unlike regular stack frames (which are destroyed when a function returns), async frames are heap-allocated and persist across suspension points. They form a linked chain through parent pointers, similar to a call stack but with the crucial difference that they can survive when the function suspends.

How the State Machine Works

The compiler transforms your async function into something conceptually like this:

// Conceptual transformation (simplified)
struct GetDataAsyncFrame {
    // Current state in the state machine
    var state: Int

    // Local variables that survive across await
    var result: Data?
    var processed: Int?

    // Parent frame pointer
    var parentFrame: UnsafePointer<AsyncFrame>?

    // Resume function
    func resume() {
        switch state {
        case 0:
            // Initial state - start fetch()
            state = 1
            // Suspend and wait for fetch() to complete
            return

        case 1:
            // Resumed after fetch() completed
            result = getAwaitResult()
            processed = process(result!)
            state = -1 // Mark as completed
            completeAsync(result: processed!)
            return
        }
    }
}

The key insight: all local variables that need to survive across await points are stored in the async frame. This moves them from the stack to the heap, ensuring they persist when the method yields control.

When you await, the method doesn’t actually wait—it:

  1. Saves the current state
  2. Registers a continuation (the resume function)
  3. Returns control to the caller
  4. Later, when the awaited operation completes, the resume function is called

Understanding Continuations

Continuations are the core mechanism that makes resumption work. A continuation is essentially “the rest of the computation”—it packages up everything needed to resume execution later.

What’s in a Continuation?

Think of a continuation as a bookmark that contains:

  • Where to resume: The specific point in the code to continue from
  • What state to restore: The local variables and context
  • Where to run: Which executor/scheduler to use when resuming

In Swift, a continuation conceptually looks like:

struct UnsafeContinuation<T> {
    // Pointer to the async frame to resume
    var asyncFrame: UnsafePointer<AsyncFrame>

    // The function to call to resume execution
    var resumeFunction: @convention(thin) (UnsafePointer<AsyncFrame>, T) -> Void

    // Which executor to resume on
    var targetExecutor: UnownedSerialExecutor

    // Priority and other metadata
    var priority: TaskPriority
}

Creating a Continuation

Let’s trace what happens when you await:

func fetchUser(id: Int) async -> User {
    let data = await networkRequest(id) // ← Suspension point
    return User(data: data)
}

At the await networkRequest(id) line:

  1. The compiler generates code to create a continuation:

    • Captures the current async frame
    • Sets the resume point (state 1 in our example)
    • Captures the current executor
  2. The async frame is updated:

    • Resume point set to 1
    • Current state saved
    • Live variables preserved
  3. Control returns to the runtime scheduler:

    • The current thread is freed up
    • Other work can execute

Resuming a Continuation

When the async operation completes, someone calls continuation.resume(). Here’s what happens internally:

func resume<T>(continuation: UnsafeContinuation<T>, with value: T) {
    // 1. Get the async frame
    let frame = continuation.asyncFrame

    // 2. Store the result value in the frame
    storeResult(in: frame, value: value)

    // 3. Check if we need to switch executors
    let targetExecutor = continuation.targetExecutor
    let currentExecutor = getCurrentExecutor()

    if currentExecutor == targetExecutor {
        // Already on correct executor - resume directly
        continuation.resumeFunction(frame, value)
    } else {
        // Need to hop to the target executor
        targetExecutor.enqueue {
            continuation.resumeFunction(frame, value)
        }
    }
}

The resume function jumps back into the async function at the right state:

func resumeFetchUser(frame: UnsafePointer<AsyncFrame>, data: Data) {
    // Restore local state from frame
    let locals = loadLocals(from: frame)

    // Jump to the right state
    switch locals.resumePoint {
    case 1:
        // Resume after networkRequest
        let user = User(data: data)
        completeAsync(frame, result: user)
    default:
        fatalError()
    }
}

Manual Continuation API

Swift exposes continuations through APIs that let you bridge callback-based code to async/await:

func legacyAsyncOperation(completion: @escaping (Result<String, Error>) -> Void) {
    // Old-style callback API
    DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
        completion(.success("Done!"))
    }
}

// Bridge to async/await using continuation:
func modernAsyncOperation() async throws -> String {
    return try await withCheckedThrowingContinuation { continuation in
        legacyAsyncOperation { result in
            // Resume the continuation with the result
            continuation.resume(with: result)
        }
    }
}

The withCheckedThrowingContinuation function:

  1. Creates a continuation for the current suspension point
  2. Passes it to your closure
  3. Suspends the async function
  4. Your closure stores the continuation and starts the async operation
  5. When the callback fires, you resume the continuation
  6. The async function continues execution

Continuation Safety

Continuations must be resumed exactly once. Swift provides both checked and unsafe versions:

// Checked - runtime verifies you resume exactly once
withCheckedContinuation { continuation in
    continuation.resume(returning: 42)
    // continuation.resume(returning: 43) // ← Runtime error!
}

// Unsafe - no checking, better performance
withUnsafeContinuation { continuation in
    continuation.resume(returning: 42)
    // Resuming twice = undefined behavior!
}

Resuming twice leads to crashes and memory corruption. Never resuming causes the async function to hang forever and leak memory. The checked version protects you from these errors at the cost of some runtime overhead.

Executor Management and Context Switching

One of the most elegant aspects of Swift’s async/await is how it manages execution context—ensuring that code runs on the correct executor (thread pool, main queue, actor’s serial queue, etc.).

How Executors Are Tracked

The key is task-local storage. Each Task carries metadata including the current executor:

struct Task {
    var id: TaskID
    var priority: TaskPriority
    var asyncFrameChain: UnsafePointer<AsyncFrame>

    // Current executor stored per-task
    var currentExecutor: UnownedSerialExecutor

    var parentTask: Task?
    // ... other metadata
}

When you call getCurrentExecutor(), it essentially does:

func swift_task_getCurrentExecutor() -> UnownedSerialExecutor {
    let currentTask = swift_task_getCurrent()  // Get current Task
    return currentTask.currentExecutor  // Return stored executor
}

How the Current Task is Tracked

The runtime maintains a pointer to the current task in each OS thread using thread-local storage:

// In each OS thread's thread-local storage
__thread Task* _currentTask = nullptr;

// Getting current task
Task* swift_task_getCurrent() {
    return _currentTask;
}

So the complete chain is:

OS Thread
   │
   └─> _currentTask (thread-local pointer)
          │
          └─> Task object (heap allocated)
                 │
                 └─> currentExecutor field

This indirection is crucial: it allows tasks to migrate between threads while maintaining their execution context.

Executor Switching with Actors

Actors in Swift guarantee that their methods run serially on their own executor. Here’s how that works with continuations:

actor DatabaseActor {
    var state: Int = 0

    func save(data: Data) async {
        // Runs on DatabaseActor's executor
        let result = await networkRequest(data)
        // Automatically back on DatabaseActor's executor!
        self.state += 1  // Safe - isolated to actor
    }
}

The flow:

  1. Before await networkRequest():

    • Currently on DatabaseActor’s executor
    • Task.currentExecutor = DatabaseActor.executor
    • Continuation captures this executor as targetExecutor
  2. networkRequest() executes:

    • May run on a completely different executor
    • Might complete on a background thread
  3. When networkRequest() completes:

    • Calls continuation.resume(returning: result)
    • Runtime checks: am I on the target executor?
    • If not, enqueues the resume operation on DatabaseActor’s executor
  4. Resume executes:

    • Now back on DatabaseActor’s executor
    • Task.currentExecutor is updated
    • Actor isolation is restored
    • Safe to access self.state

Setting the Executor

The executor is set or changed at specific points:

1. Task Creation:

// Inherits current executor
Task {
    // currentExecutor = parent's executor
    await doWork()
}

// Gets new executor from thread pool
Task.detached {
    // currentExecutor = cooperative thread pool executor
    await doWork()
}

// Explicitly on main executor
Task { @MainActor in
    // currentExecutor = MainActor's executor (main queue)
    await updateUI()
}

2. Entering Actor-Isolated Context:

When you call an actor method, the runtime switches executors:

let actor = MyActor()
await actor.doSomething()  // ← Executor switch happens here

Internally:

func callActorMethod(actor: MyActor, method: () async -> Void) async {
    let currentTask = swift_task_getCurrent()

    // Switch to actor's executor
    currentTask.currentExecutor = actor.unownedExecutor

    // Enqueue the method call on actor's executor
    actor.unownedExecutor.enqueue {
        method()  // Execute on actor's executor
    }
}

3. Resuming from Continuation:

When a continuation resumes, it restores the executor:

func resume<T>(continuation: Continuation<T>, with value: T) {
    let targetExecutor = continuation.targetExecutor

    targetExecutor.enqueue {
        let currentTask = swift_task_getCurrent()
        currentTask.currentExecutor = targetExecutor  // ← Restored here!
        continuation.resumeFunction(continuation.frame, value)
    }
}

Complete Example: Tracing Executor Changes

actor DatabaseActor {
    func save() async {
        print("A: \(getCurrentExecutor())")  // DatabaseActor's executor

        await Task {
            print("B: \(getCurrentExecutor())")  // Inherited from parent
        }.value

        print("C: \(getCurrentExecutor())")  // DatabaseActor's executor

        await Task.detached {
            print("D: \(getCurrentExecutor())")  // New executor
        }.value

        print("E: \(getCurrentExecutor())")  // DatabaseActor's executor
    }
}

What prints:

  • A: DatabaseActor’s executor (we’re in an actor method)
  • B: DatabaseActor’s executor (child Task inherits parent’s executor)
  • C: DatabaseActor’s executor (continuation restored it after await)
  • D: DefaultExecutor (detached task gets new executor)
  • E: DatabaseActor’s executor (continuation restored it again)

Performance Optimizations

Swift has optimizations to avoid unnecessary overhead:

Inline Frames

Swift can optimize away frame allocation for simple cases:

func simple() async -> Int {
    return 42  // No suspension - might not allocate frame
}

func alsoSimple() async -> Int {
    let x = await simple()  // Might inline simple's "frame"
    return x
}

If the compiler can prove an async function won’t actually suspend, it can optimize away the frame allocation entirely. This is similar to function inlining but for async operations.

Frame Reuse

For async functions called in a loop, Swift can reuse async frames instead of allocating new ones each iteration:

func processItems(_ items: [Item]) async {
    for item in items {
        await process(item)  // Frame can be reused across iterations
    }
}

Practical Implications

Understanding these internals helps you write better async code:

1. Local Variables and Captures

Variables that cross await boundaries are stored in the async frame:

func processData() async {
    let largeObject = LargeObject()  // Allocated
    await Task.sleep(nanoseconds: 1_000_000_000)
    // largeObject kept alive in async frame
    print(largeObject)
}

The largeObject is kept alive for the entire duration. If you don’t need it after the await, consider scoping it:

func processData() async {
    do {
        let largeObject = LargeObject()
        processSync(largeObject)
    }  // largeObject can be deallocated
    await Task.sleep(nanoseconds: 1_000_000_000)
    print("Done")
}

2. Continuation Safety

Never resume a continuation more than once:

// WRONG - potential double resume
func dangerous() async -> Int {
    return await withUnsafeContinuation { continuation in
        performAsync { result in
            continuation.resume(returning: result)
        }
        continuation.resume(returning: 0)  // ← BUG!
    }
}

Use checked continuations during development to catch these errors.

3. Executor Awareness

Be aware of executor hops:

actor MyActor {
    func process() async {
        let data = await fetchData()  // Executor hop out
        // Executor hop back to MyActor
        await saveData(data)  // Executor hop out
        // Executor hop back to MyActor
    }
}

Each await potentially involves executor switching. For performance-critical code, consider batching operations.

4. Avoiding Unnecessary Suspensions

If an operation might complete synchronously, you can avoid suspension overhead:

func getCachedOrFetch(key: String) async -> Value {
    // Check cache first - no suspension needed
    if let cached = cache[key] {
        return cached
    }
    // Only suspend if we need to fetch
    return await fetchFromNetwork(key)
}

Conclusion

Async/await may look like magic, but it’s built on elegant compiler transformations and runtime support:

  • State machines transform your sequential code into resumable operations
  • Continuations package up “what comes next” so execution can resume later
  • Async frames preserve local state across suspension points
  • Executor management ensures code runs in the right context

Understanding these internals helps you:

  • Write more efficient async code
  • Debug async issues more effectively
  • Make better architectural decisions
  • Appreciate the engineering behind Swift’s concurrency model

The beauty of async/await is that while the internals are complex, the programming model is simple—you write code that looks synchronous but gets all the benefits of non-blocking I/O.


Want to dive deeper? Check out the Swift Concurrency Manifesto and Swift Evolution proposals SE-0296 for more detailed technical discussions.


Serg Dort

Written by Serg Dort. Say one thing for him: he ships code.